flagnetic 0.1.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 +7 -0
- data/LICENSE +202 -0
- data/README.md +276 -0
- data/lib/flagnetic/backoff.rb +29 -0
- data/lib/flagnetic/changes.rb +88 -0
- data/lib/flagnetic/client.rb +609 -0
- data/lib/flagnetic/controller_helpers.rb +97 -0
- data/lib/flagnetic/detail.rb +73 -0
- data/lib/flagnetic/eval/evaluation.rb +382 -0
- data/lib/flagnetic/eval/instant.rb +71 -0
- data/lib/flagnetic/eval/murmur3.rb +70 -0
- data/lib/flagnetic/eval/operators.rb +225 -0
- data/lib/flagnetic/eval/regex.rb +647 -0
- data/lib/flagnetic/eval/ruleset.rb +267 -0
- data/lib/flagnetic/eval/semver.rb +80 -0
- data/lib/flagnetic/eval/types.rb +148 -0
- data/lib/flagnetic/eval/unicode_tables.rb +180 -0
- data/lib/flagnetic/eval.rb +38 -0
- data/lib/flagnetic/event_batch.rb +119 -0
- data/lib/flagnetic/exposures.rb +342 -0
- data/lib/flagnetic/fallback_chain.rb +230 -0
- data/lib/flagnetic/generations.rb +50 -0
- data/lib/flagnetic/global.rb +59 -0
- data/lib/flagnetic/local_overrides.rb +65 -0
- data/lib/flagnetic/logging.rb +62 -0
- data/lib/flagnetic/railtie.rb +146 -0
- data/lib/flagnetic/recorder.rb +135 -0
- data/lib/flagnetic/reporter.rb +94 -0
- data/lib/flagnetic/sse_parser.rb +94 -0
- data/lib/flagnetic/store.rb +34 -0
- data/lib/flagnetic/stream.rb +65 -0
- data/lib/flagnetic/summaries.rb +257 -0
- data/lib/flagnetic/testing.rb +160 -0
- data/lib/flagnetic/transport.rb +106 -0
- data/lib/flagnetic/uuid.rb +25 -0
- data/lib/flagnetic/version.rb +16 -0
- data/lib/flagnetic/worker.rb +270 -0
- data/lib/flagnetic.rb +34 -0
- metadata +79 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "generations"
|
|
4
|
+
require_relative "event_batch"
|
|
5
|
+
require_relative "reporter"
|
|
6
|
+
require_relative "uuid"
|
|
7
|
+
|
|
8
|
+
module Flagnetic
|
|
9
|
+
# Exposures queues exposures and aliases: the explicit
|
|
10
|
+
# exposure call, automatic exposure, the alias call, suppression of
|
|
11
|
+
# repeats bounded in memory, and the flush that the background thread and
|
|
12
|
+
# Client#flush/#close drive with backoff. Offline: nothing is queued,
|
|
13
|
+
# every event goes to the Recorder at the call itself.
|
|
14
|
+
#
|
|
15
|
+
# Thread-safe: evaluated, exposure and add_alias run on whatever thread
|
|
16
|
+
# called a getter or the exposure or alias method; flush_once runs on the
|
|
17
|
+
# background thread and, for an explicit flush or close, whatever thread
|
|
18
|
+
# called it.
|
|
19
|
+
class Exposures
|
|
20
|
+
include Reporter
|
|
21
|
+
|
|
22
|
+
QUEUE_CAPACITY = 10_000
|
|
23
|
+
SUPPRESSION_CAPACITY = 10_000
|
|
24
|
+
DETAIL_CAPACITY = 10_000
|
|
25
|
+
|
|
26
|
+
# ExposureRecord is what evaluated remembers for a later exposure call:
|
|
27
|
+
# the experiment block, the evaluation reason and the ruleset version
|
|
28
|
+
# the evaluation used.
|
|
29
|
+
ExposureRecord = Struct.new(:experiment, :reason, :ruleset_version, keyword_init: true)
|
|
30
|
+
|
|
31
|
+
# The predeclared attributes an exposure carries from the context.
|
|
32
|
+
ATTRIBUTE_PLATFORM = "platform"
|
|
33
|
+
ATTRIBUTE_APP_VERSION = "app_version"
|
|
34
|
+
private_constant :ATTRIBUTE_PLATFORM, :ATTRIBUTE_APP_VERSION
|
|
35
|
+
|
|
36
|
+
# wake: a callable poked after enqueuing, so a background thread
|
|
37
|
+
# sleeping on a longer, unrelated deadline notices the new event
|
|
38
|
+
# without waiting for it; a no-op where nothing needs waking (offline,
|
|
39
|
+
# or no worker yet). platform and app_version stand in for a context
|
|
40
|
+
# that names neither.
|
|
41
|
+
def initialize(timing:, log:, transport: nil, record: nil, platform: nil, app_version: nil,
|
|
42
|
+
capacity: QUEUE_CAPACITY, wake: -> {})
|
|
43
|
+
@transport = transport
|
|
44
|
+
@timing = timing
|
|
45
|
+
@log = log
|
|
46
|
+
@record = record
|
|
47
|
+
@platform = platform
|
|
48
|
+
@app_version = app_version
|
|
49
|
+
@capacity = capacity
|
|
50
|
+
@wake = wake
|
|
51
|
+
@mutex = Mutex.new
|
|
52
|
+
@queue = []
|
|
53
|
+
@suppressed = Generations.new(capacity: SUPPRESSION_CAPACITY)
|
|
54
|
+
@suppressed_since = now
|
|
55
|
+
@details = Generations.new(capacity: DETAIL_CAPACITY)
|
|
56
|
+
@experiment_flags = {}
|
|
57
|
+
@backoff = Backoff.new(base: timing.fetch_base, cap: timing.fetch_cap)
|
|
58
|
+
@not_before = 0
|
|
59
|
+
@flushed_at = now
|
|
60
|
+
@revoked_logged = false
|
|
61
|
+
@send_mutex = Mutex.new
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# offline? reports whether events go to the Recorder rather than to the
|
|
65
|
+
# events endpoint. An offline queue is driven by nothing: every event is
|
|
66
|
+
# recorded at the call itself.
|
|
67
|
+
def offline? = !@record.nil?
|
|
68
|
+
|
|
69
|
+
# evaluated remembers the detail of one evaluation for a later exposure
|
|
70
|
+
# call, and records the exposure at once for a flag with
|
|
71
|
+
# automatic_exposure. A flag whose evaluation stops carrying an
|
|
72
|
+
# experiment block is forgotten, so a later exposure call reports
|
|
73
|
+
# nothing rather than the last assignment of an experiment the ruleset
|
|
74
|
+
# no longer serves. The fingerprint of context is computed only when a
|
|
75
|
+
# detail actually needs remembering or forgetting: the great majority
|
|
76
|
+
# of evaluations, of flags that have never carried an experiment,
|
|
77
|
+
# touch nothing but @experiment_flags.
|
|
78
|
+
def evaluated(context, key, detail, automatic)
|
|
79
|
+
unless detail.experiment
|
|
80
|
+
forget(detail_key(key, context), detail.ruleset_version) if known?(key)
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
mark_known(key)
|
|
84
|
+
record = ExposureRecord.new(experiment: detail.experiment, reason: detail.reason,
|
|
85
|
+
ruleset_version: detail.ruleset_version)
|
|
86
|
+
remember(detail_key(key, context), record)
|
|
87
|
+
emit(context, key, record) if automatic
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# exposure looks up the most recent detail of key for context and
|
|
91
|
+
# enqueues an exposure only when it carries an experiment block;
|
|
92
|
+
# otherwise nothing is sent and exposure_skipped names why.
|
|
93
|
+
def exposure(context, key)
|
|
94
|
+
record, found = recall(detail_key(key, context))
|
|
95
|
+
return skipped(key, cause(key)) unless found
|
|
96
|
+
|
|
97
|
+
emit(context, key, record)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# add_alias enqueues an alias event linking two unit IDs.
|
|
101
|
+
def add_alias(from_unit_type, from_unit_id, to_unit_type, to_unit_id)
|
|
102
|
+
t = Time.now
|
|
103
|
+
enqueue({ "type" => "alias", "event_id" => UUID.v7(time: t), "from_unit_type" => from_unit_type.to_s,
|
|
104
|
+
"from_unit_id" => from_unit_id.to_s, "to_unit_type" => to_unit_type.to_s,
|
|
105
|
+
"to_unit_id" => to_unit_id.to_s, "occurred_at" => EventBatch.format_time(t) })
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# due? reports whether a flush should run now: something is queued, the
|
|
109
|
+
# flush interval has passed since the last flush or a full batch is
|
|
110
|
+
# waiting, and the backoff since the last failure has elapsed.
|
|
111
|
+
def due?(mono = now)
|
|
112
|
+
return false if offline? || mono < @not_before
|
|
113
|
+
|
|
114
|
+
size = @mutex.synchronize { @queue.size }
|
|
115
|
+
size.positive? && (mono - @flushed_at >= @timing.exposure_flush || size >= EventBatch::MAX_EVENTS_PER_BATCH)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# next_deadline is when due? might next turn true, for a worker's wait
|
|
119
|
+
# to schedule around; nil when nothing is queued (offline or not). An
|
|
120
|
+
# enqueue wakes the worker, so a batch filling up is seen at once.
|
|
121
|
+
def next_deadline
|
|
122
|
+
size = @mutex.synchronize { @queue.size }
|
|
123
|
+
return nil if offline? || size.zero?
|
|
124
|
+
return @not_before if size >= EventBatch::MAX_EVENTS_PER_BATCH
|
|
125
|
+
|
|
126
|
+
[@not_before, @flushed_at + @timing.exposure_flush].max
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# flush_once sends what is queued, retrying with backoff within budget
|
|
130
|
+
# seconds; whatever is not sent stays queued for a later attempt. Safe
|
|
131
|
+
# to call from any thread; concurrent callers wait for each other only
|
|
132
|
+
# up to budget, so one never blocks behind another past its own.
|
|
133
|
+
def flush_once(budget)
|
|
134
|
+
return true if offline?
|
|
135
|
+
|
|
136
|
+
deadline = now + budget
|
|
137
|
+
return true unless wait_for_send_lock(deadline)
|
|
138
|
+
|
|
139
|
+
begin
|
|
140
|
+
@flushed_at = now
|
|
141
|
+
send_queue(deadline)
|
|
142
|
+
ensure
|
|
143
|
+
@send_mutex.unlock
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# reset_after_fork empties the queue in a forked child, which starts
|
|
148
|
+
# empty; the parent, which never calls this, keeps its own.
|
|
149
|
+
def reset_after_fork
|
|
150
|
+
@mutex.synchronize { @queue.clear }
|
|
151
|
+
@backoff.reset
|
|
152
|
+
@not_before = 0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def kind = "exposure_or_alias"
|
|
158
|
+
|
|
159
|
+
def known?(key) = @mutex.synchronize { @experiment_flags[key] }
|
|
160
|
+
def mark_known(key) = @mutex.synchronize { @experiment_flags[key] = true }
|
|
161
|
+
|
|
162
|
+
# emit builds the exposure and enqueues it, unless the same unit has
|
|
163
|
+
# already been recorded seeing this variation of this experiment within
|
|
164
|
+
# the suppression window.
|
|
165
|
+
def emit(context, key, record)
|
|
166
|
+
exp = record.experiment
|
|
167
|
+
if suppressed?([exp.key, exp.unit_type, exp.unit_id, exp.variation])
|
|
168
|
+
skipped(key, "repeat")
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
t = Time.now
|
|
172
|
+
coerced = begin
|
|
173
|
+
Eval::Context.__send__(:coerce, context)
|
|
174
|
+
rescue StandardError
|
|
175
|
+
Eval::Context::EMPTY
|
|
176
|
+
end
|
|
177
|
+
enqueue({ "type" => "exposure", "event_id" => UUID.v7(time: t), "experiment_key" => exp.key, "flag_key" => key,
|
|
178
|
+
"variation_key" => exp.variation, "unit_type" => exp.unit_type, "unit_id" => exp.unit_id,
|
|
179
|
+
"platform" => predeclared(coerced, ATTRIBUTE_PLATFORM) || @platform,
|
|
180
|
+
"app_version" => predeclared(coerced, ATTRIBUTE_APP_VERSION) || @app_version,
|
|
181
|
+
"evaluation_reason" => record.reason.to_s, "override" => exp.override,
|
|
182
|
+
"ruleset_version" => record.ruleset_version, "occurred_at" => EventBatch.format_time(t) })
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def skipped(key, cause)
|
|
186
|
+
log(:debug, :exposure_skipped, flag: key, cause: cause)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# cause tells the two mistakes behind an exposure call with nothing to
|
|
190
|
+
# report apart, as far as the SDK can: a flag no evaluation of which
|
|
191
|
+
# has ever carried an experiment block, and a flag that has, but not
|
|
192
|
+
# for this context.
|
|
193
|
+
def cause(key) = known?(key) ? "no_detail" : "no_experiment"
|
|
194
|
+
|
|
195
|
+
def suppressed?(key)
|
|
196
|
+
@mutex.synchronize do
|
|
197
|
+
t = now
|
|
198
|
+
if t - @suppressed_since >= @timing.exposure_flush
|
|
199
|
+
@suppressed.expire
|
|
200
|
+
@suppressed_since = t
|
|
201
|
+
end
|
|
202
|
+
_, seen = @suppressed.load(key)
|
|
203
|
+
@suppressed.store(key, true) unless seen
|
|
204
|
+
seen
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def detail_key(flag, context) = [flag, fingerprint(context)]
|
|
209
|
+
|
|
210
|
+
def remember(key, record)
|
|
211
|
+
@mutex.synchronize do
|
|
212
|
+
held, ok = @details.load(key)
|
|
213
|
+
next if ok && held.ruleset_version > record.ruleset_version
|
|
214
|
+
|
|
215
|
+
@details.store(key, record)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def forget(key, ruleset_version)
|
|
220
|
+
@mutex.synchronize do
|
|
221
|
+
held, ok = @details.load(key)
|
|
222
|
+
next if ok && held.ruleset_version > ruleset_version
|
|
223
|
+
|
|
224
|
+
@details.delete(key)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def recall(key) = @mutex.synchronize { @details.load(key) }
|
|
229
|
+
|
|
230
|
+
# fingerprint is a canonical, in-process encoding of a context, so an
|
|
231
|
+
# exposure call finds the detail of the very context the flag was
|
|
232
|
+
# evaluated for. It never leaves the process and needs no cross-language
|
|
233
|
+
# stability.
|
|
234
|
+
def fingerprint(context)
|
|
235
|
+
c = Eval::Context.__send__(:coerce, context)
|
|
236
|
+
units = c.units.sort.flatten
|
|
237
|
+
attributes = c.attributes.sort.map { |k, v| [k, attribute_token(v)] }.flatten
|
|
238
|
+
(units + [""] + attributes).join(" ")
|
|
239
|
+
rescue StandardError
|
|
240
|
+
context.object_id.to_s
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def attribute_token(value)
|
|
244
|
+
case value
|
|
245
|
+
when String, Numeric, true, false then value.inspect
|
|
246
|
+
when Array then value.map(&:inspect).join(",")
|
|
247
|
+
when Time then value.utc.iso8601(3)
|
|
248
|
+
else value.to_s
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# predeclared reads one predeclared attribute as a string from an
|
|
253
|
+
# already-coerced context; nil when the context names none or names it
|
|
254
|
+
# as another type, and the client's option then stands in.
|
|
255
|
+
def predeclared(coerced_context, name)
|
|
256
|
+
v = coerced_context.attributes[name]
|
|
257
|
+
v.is_a?(String) ? v : nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# enqueue puts one event in the queue, dropping the oldest when the
|
|
261
|
+
# queue is full, and wakes whatever is waiting for a longer deadline.
|
|
262
|
+
# An offline queue hands the event to the Recorder instead.
|
|
263
|
+
def enqueue(event)
|
|
264
|
+
if offline?
|
|
265
|
+
@record.call(event)
|
|
266
|
+
return
|
|
267
|
+
end
|
|
268
|
+
dropped = @mutex.synchronize do
|
|
269
|
+
@queue << event
|
|
270
|
+
excess = @queue.size - @capacity
|
|
271
|
+
excess.positive? ? @queue.shift(excess).size : 0
|
|
272
|
+
end
|
|
273
|
+
@wake.call
|
|
274
|
+
return unless dropped.positive?
|
|
275
|
+
|
|
276
|
+
log(:warn, :queue_dropped, kind: kind, cause: "queue_full", events: dropped, capacity: @capacity)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def take = @mutex.synchronize { @queue.dup.tap { @queue.clear } }
|
|
280
|
+
|
|
281
|
+
# put_back returns events the platform did not answer to the front of
|
|
282
|
+
# the queue, so the next flush sends them again with the same event
|
|
283
|
+
# IDs. They are the oldest, so they are the first dropped when the
|
|
284
|
+
# queue is full.
|
|
285
|
+
def put_back(events)
|
|
286
|
+
return if events.empty?
|
|
287
|
+
|
|
288
|
+
dropped = @mutex.synchronize do
|
|
289
|
+
@queue = events + @queue
|
|
290
|
+
excess = @queue.size - @capacity
|
|
291
|
+
excess.positive? ? @queue.shift(excess).size : 0
|
|
292
|
+
end
|
|
293
|
+
return unless dropped.positive?
|
|
294
|
+
|
|
295
|
+
log(:warn, :queue_dropped, kind: kind, cause: "queue_full", events: dropped, capacity: @capacity)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def send_queue(deadline)
|
|
299
|
+
events = take
|
|
300
|
+
return true if events.empty?
|
|
301
|
+
|
|
302
|
+
batches = EventBatch.pack(events, @transport.identity)
|
|
303
|
+
batches.each_with_index do |group, i|
|
|
304
|
+
result = attempt(group, deadline)
|
|
305
|
+
case result.kind
|
|
306
|
+
when :accepted
|
|
307
|
+
@backoff.reset
|
|
308
|
+
@not_before = 0
|
|
309
|
+
@revoked_logged = false
|
|
310
|
+
report_accepted(group, result)
|
|
311
|
+
when :dropped
|
|
312
|
+
report_dropped(group, result)
|
|
313
|
+
else # :retry or :unauthorized ran out of the flush's budget
|
|
314
|
+
put_back(batches[i..].flatten(1))
|
|
315
|
+
return false
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
true
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# schedule_retry backs off on its own schedule rather than honouring
|
|
322
|
+
# Retry-After: the flush budget is seconds, and a header naming
|
|
323
|
+
# minutes must not hold exposures back for minutes (the protocol
|
|
324
|
+
# allows either choice).
|
|
325
|
+
def schedule_retry(group, result)
|
|
326
|
+
wait = @backoff.next_delay(cap: @timing.fetch_cap)
|
|
327
|
+
@not_before = now + wait
|
|
328
|
+
log_ingest_failed(group, result, wait)
|
|
329
|
+
wait
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def report_accepted(group, result)
|
|
333
|
+
return if result.rejected.to_a.empty?
|
|
334
|
+
|
|
335
|
+
log(:warn, :queue_dropped, kind: kind, cause: "refused_by_the_platform",
|
|
336
|
+
events: group.map { |e| e["event_id"] },
|
|
337
|
+
accepted: result.accepted, duplicates: result.duplicates,
|
|
338
|
+
rejected: result.rejected.map { |r| r["code"] })
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
private_constant :Exposures
|
|
342
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Flagnetic
|
|
6
|
+
# FallbackChain is the part of Client that holds the ruleset: it loads
|
|
7
|
+
# the store and offline documents, applies each fetch outcome and moves
|
|
8
|
+
# the stage. Every method is private; the background thread calls handle_fetch,
|
|
9
|
+
# held_version and key_revoked.
|
|
10
|
+
module FallbackChain
|
|
11
|
+
# Loaded is the held ruleset and when it was received.
|
|
12
|
+
Loaded = Struct.new(:ruleset, :received_at, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def held_version = @loaded&.ruleset&.version
|
|
17
|
+
|
|
18
|
+
def load_offline
|
|
19
|
+
ruleset = decode(@offline_source.call)
|
|
20
|
+
unless ruleset.is_a?(Eval::Ruleset)
|
|
21
|
+
cause = ruleset.is_a?(Array) ? "schema_version #{ruleset[1]} is unsupported" : ruleset
|
|
22
|
+
raise OfflineError, "offline ruleset: #{cause}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
swap(ruleset, received_at: Time.now, stage: :fresh, source: "offline")
|
|
26
|
+
rescue OfflineError
|
|
27
|
+
raise
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
# The source may be the application's own callable: whatever it
|
|
30
|
+
# raises is a failed document, never an error escaping Client.new.
|
|
31
|
+
raise OfflineError, "offline ruleset: #{e.class}: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# load_store serves a persisted ruleset as last_good before any network
|
|
35
|
+
# call. A corrupt file leaves the stage built_in, with a log.
|
|
36
|
+
def load_store
|
|
37
|
+
json, received_at = @store.load
|
|
38
|
+
return unless json
|
|
39
|
+
|
|
40
|
+
ruleset = decode(json)
|
|
41
|
+
unless ruleset.is_a?(Eval::Ruleset)
|
|
42
|
+
cause = ruleset.is_a?(Array) ? "schema_version #{ruleset[1]} is unsupported" : ruleset
|
|
43
|
+
return log(:error, :store_failed, path: @store.to_s, cause: cause)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
swap(ruleset, received_at: received_at, stage: :last_good, source: "store")
|
|
47
|
+
rescue SystemCallError, IOError => e
|
|
48
|
+
log(:error, :store_failed, path: @store.to_s, cause: "#{e.class}: #{e.message}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# decode returns the ruleset a fetched or stored document holds, or the
|
|
52
|
+
# reason it is refused: it must parse, carry a supported schema_version
|
|
53
|
+
# and be of the server scope.
|
|
54
|
+
def decode(json)
|
|
55
|
+
# The nesting limit is the evaluator's own, so a document it reads is
|
|
56
|
+
# never refused here first.
|
|
57
|
+
doc = JSON.parse(json, max_nesting: 10_000)
|
|
58
|
+
return "parse: the document is not a JSON object" unless doc.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
schema = doc["schema_version"]
|
|
61
|
+
return "parse: schema_version is missing" unless schema.is_a?(Integer)
|
|
62
|
+
return [:schema, schema] unless schema.between?(SCHEMA_VERSION_MIN, SCHEMA_VERSION_MAX)
|
|
63
|
+
return "parse: scope is #{doc["scope"].inspect}, want \"server\"" unless doc["scope"] == "server"
|
|
64
|
+
|
|
65
|
+
Eval::Ruleset.decode(json)
|
|
66
|
+
rescue JSON::ParserError, EncodingError => e
|
|
67
|
+
"parse: #{e.message.lines.first&.strip}"
|
|
68
|
+
rescue Eval::DecodeError => e
|
|
69
|
+
"parse: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# swap installs a ruleset, moves to stage and returns the flags whose
|
|
73
|
+
# evaluation may have changed, local overrides dropped by a protected
|
|
74
|
+
# environment included.
|
|
75
|
+
def swap(ruleset, received_at:, stage:, source:)
|
|
76
|
+
# The ruleset and its stage change together, so no getter sees one
|
|
77
|
+
# without the other.
|
|
78
|
+
previous, previous_stage = @mutex.synchronize do
|
|
79
|
+
p = [@loaded, @stage]
|
|
80
|
+
@loaded = Loaded.new(ruleset: ruleset, received_at: received_at).freeze
|
|
81
|
+
@stage = stage
|
|
82
|
+
p
|
|
83
|
+
end
|
|
84
|
+
log(:info, :ruleset_loaded, source: source, ruleset_version: ruleset.version, flags: ruleset.flags.size)
|
|
85
|
+
report_unsupported(ruleset)
|
|
86
|
+
stage_changed(previous_stage, stage)
|
|
87
|
+
(Changes.flags(previous&.ruleset, ruleset) | refuse_local_overrides(ruleset)).sort
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# report_unsupported logs rule_unsupported for every flag of ruleset
|
|
91
|
+
# holding a construct this evaluator does not know, once per flag and
|
|
92
|
+
# ruleset version: the evaluator finds them when it decodes, so a flag
|
|
93
|
+
# nobody evaluates is reported too, and an SDK that must be upgraded
|
|
94
|
+
# says so without a line per evaluation.
|
|
95
|
+
def report_unsupported(ruleset)
|
|
96
|
+
ruleset.unsupported_rules.sort.each do |key, constructs|
|
|
97
|
+
next unless first_report?(key, ruleset.version)
|
|
98
|
+
|
|
99
|
+
log(:error, :rule_unsupported, flag: key, constructs: constructs.map { |c| "#{c.kind} #{c.name}" },
|
|
100
|
+
ruleset_version: ruleset.version)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def first_report?(key, version)
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
next false if @unsupported_logged[key] == version
|
|
107
|
+
|
|
108
|
+
@unsupported_logged[key] = version
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def move_to(stage)
|
|
114
|
+
previous = @mutex.synchronize do
|
|
115
|
+
p = @stage
|
|
116
|
+
@stage = stage
|
|
117
|
+
p
|
|
118
|
+
end
|
|
119
|
+
stage_changed(previous, stage)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def stage_changed(previous, stage)
|
|
123
|
+
return if previous == stage
|
|
124
|
+
|
|
125
|
+
log(stage == :fresh ? :info : :warn, :stage_changed, from: previous, to: stage)
|
|
126
|
+
fire(@stage_listeners, stage)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# handle_fetch applies a fetch outcome (a Transport::Response or the
|
|
130
|
+
# exception raised) to the fallback chain and returns :ok, :failed or
|
|
131
|
+
# :revoked. Called by the background thread.
|
|
132
|
+
def handle_fetch(response)
|
|
133
|
+
return fetch_failed("#{response.class}: #{response.message}") if response.is_a?(Exception)
|
|
134
|
+
|
|
135
|
+
case response.status
|
|
136
|
+
when 200 then accept(response.body)
|
|
137
|
+
when 304 then not_modified
|
|
138
|
+
when 401, 403 then key_revoked(response.status, "fetch")
|
|
139
|
+
else fetch_failed("HTTP #{response.status}")
|
|
140
|
+
end
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
# An unexpected failure applying the outcome is a failed fetch, so the
|
|
143
|
+
# stage and the retry schedule move as for any other.
|
|
144
|
+
fetch_failed("#{e.class}: #{e.message}")
|
|
145
|
+
ensure
|
|
146
|
+
# ready waits for the first outcome, whatever it was.
|
|
147
|
+
mark_ready
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def accept(json)
|
|
151
|
+
ruleset = decode(json)
|
|
152
|
+
return schema_unsupported(ruleset[1]) if ruleset.is_a?(Array)
|
|
153
|
+
return fetch_failed(ruleset) if ruleset.is_a?(String)
|
|
154
|
+
|
|
155
|
+
held = held_version
|
|
156
|
+
return fetch_failed("version #{ruleset.version} is below the held #{held}") if held && ruleset.version < held
|
|
157
|
+
|
|
158
|
+
changed = swap(ruleset, received_at: Time.now, stage: :fresh, source: "fetch")
|
|
159
|
+
succeeded
|
|
160
|
+
persist(json)
|
|
161
|
+
fire_change(changed)
|
|
162
|
+
:ok
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def not_modified
|
|
166
|
+
return fetch_failed("304 without a held ruleset") unless @loaded
|
|
167
|
+
|
|
168
|
+
move_to(:fresh)
|
|
169
|
+
succeeded
|
|
170
|
+
:ok
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def fetch_failed(cause)
|
|
174
|
+
log(:warn, :fetch_failed, cause: cause, stage: @stage)
|
|
175
|
+
failed
|
|
176
|
+
:failed
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# failed moves fresh to last_good and marks the held ruleset stale;
|
|
180
|
+
# last_good and built_in stay.
|
|
181
|
+
def failed
|
|
182
|
+
@mutex.synchronize { @stale_since ||= Time.now }
|
|
183
|
+
move_to(:last_good) if @stage == :fresh
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def succeeded
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
@stale_since = nil
|
|
189
|
+
@once.delete([:key_revoked, "fetch"])
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def schema_unsupported(schema)
|
|
194
|
+
once(:schema_unsupported) do
|
|
195
|
+
log(:error, :schema_unsupported, schema_version: schema,
|
|
196
|
+
supported: (SCHEMA_VERSION_MIN..SCHEMA_VERSION_MAX).to_a)
|
|
197
|
+
end
|
|
198
|
+
fetch_failed("schema_version #{schema} is unsupported")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# key_revoked keeps serving: the stage leaves fresh as for any failure,
|
|
202
|
+
# key_revoked is logged once at error per source, until a fetch
|
|
203
|
+
# succeeds again or the stream connects again. A refused fetch is a
|
|
204
|
+
# failure of the fallback chain; a refused stream only closes it.
|
|
205
|
+
def key_revoked(status, source)
|
|
206
|
+
once([:key_revoked, source]) { log(:error, :key_revoked, status: status, source: source) }
|
|
207
|
+
failed if source == "fetch"
|
|
208
|
+
:revoked
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def stream_connected
|
|
212
|
+
@mutex.synchronize { @once.delete([:key_revoked, "stream"]) }
|
|
213
|
+
log(:info, :sse_connected)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def persist(json)
|
|
217
|
+
@store&.save(json)
|
|
218
|
+
rescue SystemCallError, IOError => e
|
|
219
|
+
log(:error, :store_failed, path: @store.to_s, cause: "#{e.class}: #{e.message}")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def mark_ready
|
|
223
|
+
@ready_mutex.synchronize do
|
|
224
|
+
@ready = true
|
|
225
|
+
@ready_cond.broadcast
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
private_constant :FallbackChain
|
|
230
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
# Generations is a bounded map whose entries survive one rotation: a
|
|
5
|
+
# lookup sees the current generation and the one before it, and a
|
|
6
|
+
# rotation forgets only the older one. It bounds memory without a timer
|
|
7
|
+
# and without the bookkeeping of an eviction order, which is what a
|
|
8
|
+
# suppression set the platform does not rely on needs.
|
|
9
|
+
#
|
|
10
|
+
# It holds no clock and no window: when a generation has lived long
|
|
11
|
+
# enough is the caller's to decide, by calling expire. A full generation
|
|
12
|
+
# rotates on its own, because the bound is the whole point.
|
|
13
|
+
#
|
|
14
|
+
# Not safe for concurrent use; the caller holds a mutex over it.
|
|
15
|
+
class Generations
|
|
16
|
+
def initialize(capacity:)
|
|
17
|
+
@capacity = capacity
|
|
18
|
+
@current = {}
|
|
19
|
+
@previous = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# load returns [value, true] from either generation, or [nil, false].
|
|
23
|
+
def load(key)
|
|
24
|
+
return [@current[key], true] if @current.key?(key)
|
|
25
|
+
return [@previous[key], true] if @previous.key?(key)
|
|
26
|
+
|
|
27
|
+
[nil, false]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# store writes key into the current generation, expiring the older one
|
|
31
|
+
# first when the current one is full.
|
|
32
|
+
def store(key, value)
|
|
33
|
+
expire if !@current.key?(key) && @current.size >= @capacity
|
|
34
|
+
@current[key] = value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# delete forgets key in both generations.
|
|
38
|
+
def delete(key)
|
|
39
|
+
@current.delete(key)
|
|
40
|
+
@previous.delete(key)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# expire forgets the older generation and makes the current one older.
|
|
44
|
+
def expire
|
|
45
|
+
@previous = @current
|
|
46
|
+
@current = {}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
private_constant :Generations
|
|
50
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Flagnetic.client: the process-wide client.
|
|
4
|
+
module Flagnetic
|
|
5
|
+
# NotConfigured is raised by Flagnetic.client when no client was assigned
|
|
6
|
+
# and no builder was set.
|
|
7
|
+
class NotConfigured < Error; end
|
|
8
|
+
|
|
9
|
+
@global_mutex = Mutex.new
|
|
10
|
+
@client = nil
|
|
11
|
+
@client_builder = nil
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# client returns the process-wide client: the one assigned with
|
|
15
|
+
# client=, or the one client_builder builds on first use, once, however
|
|
16
|
+
# many threads ask at the same time. The Rails integration sets either.
|
|
17
|
+
# Raises NotConfigured when neither was set.
|
|
18
|
+
#
|
|
19
|
+
# Flagnetic.client = Flagnetic::Client.new(ENV.fetch("FLAGNETIC_SDK_KEY"), base_url: "https://flags.example.com")
|
|
20
|
+
# Flagnetic.client.bool("new_checkout", default: false, context: { units: { user: "u-1001" } })
|
|
21
|
+
def client
|
|
22
|
+
@client || @global_mutex.synchronize do
|
|
23
|
+
@client ||= @client_builder&.call or
|
|
24
|
+
raise NotConfigured, "Flagnetic.client is not set: assign one with Flagnetic.client= " \
|
|
25
|
+
"or use the Rails integration"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# client= assigns the process-wide client. It does not close the one it
|
|
30
|
+
# replaces.
|
|
31
|
+
def client=(client)
|
|
32
|
+
@global_mutex.synchronize { @client = client }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# client_builder= sets a callable building the process-wide client on
|
|
36
|
+
# the first call to client, unless one is assigned before.
|
|
37
|
+
def client_builder=(builder)
|
|
38
|
+
@global_mutex.synchronize { @client_builder = builder }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# client? tells whether the process-wide client exists, without
|
|
42
|
+
# building it.
|
|
43
|
+
def client? = !@client.nil?
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# replace_client swaps the assigned client, and the builder unless it
|
|
48
|
+
# is :keep, returning the previous pair; Testing.with_flags restores it
|
|
49
|
+
# without building a client that was never built.
|
|
50
|
+
def replace_client(client, builder = :keep)
|
|
51
|
+
@global_mutex.synchronize do
|
|
52
|
+
previous = [@client, @client_builder]
|
|
53
|
+
@client = client
|
|
54
|
+
@client_builder = builder unless builder == :keep
|
|
55
|
+
previous
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|