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,609 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Flagnetic
|
|
6
|
+
# Client evaluates flags in server mode: it holds the environment's
|
|
7
|
+
# ruleset in memory and evaluates locally. Construct one per process and
|
|
8
|
+
# share it across threads; it survives fork.
|
|
9
|
+
#
|
|
10
|
+
# client = Flagnetic::Client.new(ENV.fetch("FLAGNETIC_SDK_KEY"), base_url: "https://flags.example.com")
|
|
11
|
+
# client.ready(timeout: 5) # => :fresh, :last_good or :built_in
|
|
12
|
+
# client.bool("new_checkout", default: false, context: { units: { user: "u-1001" } })
|
|
13
|
+
#
|
|
14
|
+
# Threads: the ruleset is a frozen object swapped under a mutex and read
|
|
15
|
+
# without locking, so getters never wait. One background thread runs the
|
|
16
|
+
# change stream, polling and retries.
|
|
17
|
+
#
|
|
18
|
+
# Fork: the first call in a forked child (Puma cluster mode, Unicorn)
|
|
19
|
+
# sees the new process ID and starts the child's own background thread;
|
|
20
|
+
# the child keeps serving the ruleset it inherited meanwhile. after_fork
|
|
21
|
+
# starts it at once, from a fork hook.
|
|
22
|
+
#
|
|
23
|
+
# Getters never raise: any failure serves the built-in default with
|
|
24
|
+
# reason fallback and a log event.
|
|
25
|
+
class Client
|
|
26
|
+
include FallbackChain
|
|
27
|
+
|
|
28
|
+
# Timing holds the delays and intervals of the background thread, in
|
|
29
|
+
# seconds. Only poll_interval and request_timeout are public options;
|
|
30
|
+
# the timing option exists for tests and is not part of the API.
|
|
31
|
+
Timing = Struct.new(:request_timeout, :fetch_base, :fetch_cap, :unauthorized_cap, :stream_base, :stream_cap,
|
|
32
|
+
:stream_stable, :close_jitter, :silence, :poll_connected, :poll_down, :event_retries,
|
|
33
|
+
:exposure_flush, :summary_flush, keyword_init: true)
|
|
34
|
+
DEFAULT_TIMING = Timing.new(
|
|
35
|
+
request_timeout: 10, fetch_base: 1, fetch_cap: 300, unauthorized_cap: 900, stream_base: 1, stream_cap: 30,
|
|
36
|
+
stream_stable: 60, close_jitter: 2, silence: 45, poll_connected: 300, poll_down: 30, event_retries: [1, 2, 4],
|
|
37
|
+
exposure_flush: 2, summary_flush: 60
|
|
38
|
+
).freeze
|
|
39
|
+
MIN_POLL_INTERVAL = 30
|
|
40
|
+
TYPES = %w[boolean string number json].freeze
|
|
41
|
+
private_constant :Timing, :DEFAULT_TIMING, :TYPES
|
|
42
|
+
|
|
43
|
+
# new constructs a client for a server key and the base URL of the
|
|
44
|
+
# instance. It fails loudly (ArgumentError) only for a client key and
|
|
45
|
+
# for arguments that cannot work (no base_url); never for network
|
|
46
|
+
# reasons. Options:
|
|
47
|
+
#
|
|
48
|
+
# - offline: a ruleset document (JSON text or a Hash) exactly as the
|
|
49
|
+
# ruleset endpoint serves it, or a callable returning one on every
|
|
50
|
+
# reload; offline_file: a path to one. No network
|
|
51
|
+
# call is ever made; the stage is fresh; reload re-reads the source.
|
|
52
|
+
# - store: a file path holding the last good ruleset, loaded as
|
|
53
|
+
# last_good at construction and rewritten on every accepted ruleset.
|
|
54
|
+
# - logger: a callable receiving (level, event, fields), or a Logger.
|
|
55
|
+
# Default: warn and above to $stderr.
|
|
56
|
+
# - stream: false disables the change stream; polling then runs every
|
|
57
|
+
# 30 s.
|
|
58
|
+
# - poll_interval: seconds between safety polls while the stream is
|
|
59
|
+
# connected (default 300, at least 30).
|
|
60
|
+
# - request_timeout: seconds per request (default 10).
|
|
61
|
+
# - allow_overrides: enables local_overrides (logged as a warning).
|
|
62
|
+
# - platform: sent in User-Agent and the events envelope, and an
|
|
63
|
+
# exposure's platform where the context names none (default
|
|
64
|
+
# "server").
|
|
65
|
+
# - app_version: the application's version, sent with every evaluation
|
|
66
|
+
# summary and as an exposure's app_version where the context names
|
|
67
|
+
# none.
|
|
68
|
+
# - flush_interval: seconds between flushes of the exposure and alias
|
|
69
|
+
# queue (default 2); also how long one batch may be retried before
|
|
70
|
+
# its events go back into the queue, and how long a repeat per
|
|
71
|
+
# experiment, unit and variation is suppressed for.
|
|
72
|
+
# - summary_interval: seconds between evaluation summary reports
|
|
73
|
+
# (default 60); also how long one report may be retried before its
|
|
74
|
+
# window merges into the next.
|
|
75
|
+
# - send_summaries: false stops evaluation summaries from being counted
|
|
76
|
+
# and sent (default true).
|
|
77
|
+
# - send_exposures: false stops exposure and alias from sending
|
|
78
|
+
# anything (default true; logged as a warning at construction).
|
|
79
|
+
def initialize(sdk_key = nil, base_url: nil, offline: nil, offline_file: nil, store: nil, logger: nil,
|
|
80
|
+
stream: true, poll_interval: 300, request_timeout: 10, allow_overrides: false,
|
|
81
|
+
platform: "server", app_version: nil, flush_interval: 2, summary_interval: 60,
|
|
82
|
+
send_summaries: true, send_exposures: true, timing: nil)
|
|
83
|
+
if sdk_key.to_s.start_with?("fn_cli_")
|
|
84
|
+
raise ArgumentError, "Flagnetic::Client is server mode and needs a server key (fn_srv_...); " \
|
|
85
|
+
"a client key (fn_cli_...) belongs to a client-mode SDK"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
@log = Logging.adapt(logger)
|
|
89
|
+
@allow_overrides = allow_overrides
|
|
90
|
+
@send_summaries = send_summaries != false
|
|
91
|
+
@send_exposures = send_exposures != false
|
|
92
|
+
@platform = platform
|
|
93
|
+
@app_version = app_version&.to_s
|
|
94
|
+
init_state
|
|
95
|
+
log(:warn, :local_overrides_allowed) if allow_overrides
|
|
96
|
+
log(:warn, :exposures_disabled) unless @send_exposures
|
|
97
|
+
@timing = build_timing(poll_interval, request_timeout, flush_interval, summary_interval, timing)
|
|
98
|
+
|
|
99
|
+
if offline || offline_file
|
|
100
|
+
start_offline(offline, offline_file)
|
|
101
|
+
else
|
|
102
|
+
raise ArgumentError, "base_url is required unless the client is offline" if base_url.nil?
|
|
103
|
+
|
|
104
|
+
start_online(sdk_key, base_url, store: store, stream: stream, platform: platform)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# bool serves a boolean flag for context, or default with reason
|
|
109
|
+
# fallback.
|
|
110
|
+
def bool(key, default:, context: nil) = serve(key, "boolean", default, context).value
|
|
111
|
+
|
|
112
|
+
# string serves a string flag for context, or default.
|
|
113
|
+
def string(key, default:, context: nil) = serve(key, "string", default, context).value
|
|
114
|
+
|
|
115
|
+
# number serves a number flag (an Integer or Float, as the ruleset has
|
|
116
|
+
# it) for context, or default.
|
|
117
|
+
def number(key, default:, context: nil) = serve(key, "number", default, context).value
|
|
118
|
+
|
|
119
|
+
# int serves a number flag as an Integer for context, or default. A
|
|
120
|
+
# served number that is not whole serves default with error
|
|
121
|
+
# type_mismatch rather than a truncated value.
|
|
122
|
+
def int(key, default:, context: nil) = serve(key, "number", default, context, int: true).value
|
|
123
|
+
|
|
124
|
+
# json serves a JSON flag's value, parsed and frozen, for context, or
|
|
125
|
+
# default.
|
|
126
|
+
def json(key, default:, context: nil) = serve(key, "json", default, context).value
|
|
127
|
+
|
|
128
|
+
# detail serves a flag with why it was served. default's class names the
|
|
129
|
+
# flag type asked for: true or false is boolean, a String is string, a
|
|
130
|
+
# Numeric is number, anything else JSON.
|
|
131
|
+
def detail(key, default:, context: nil) = serve(key, Client.__send__(:type_of, default), default, context)
|
|
132
|
+
|
|
133
|
+
# stage returns the current stage of the fallback chain: :built_in,
|
|
134
|
+
# :last_good or :fresh.
|
|
135
|
+
def stage
|
|
136
|
+
ensure_worker
|
|
137
|
+
@stage
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# status returns the stage and since when the held ruleset is stale.
|
|
141
|
+
def status
|
|
142
|
+
ensure_worker
|
|
143
|
+
@mutex.synchronize { Status.new(stage: @stage, stale_since: @stale_since) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ready waits until the first fetch has an outcome, or timeout seconds,
|
|
147
|
+
# and returns the stage reached. It never raises. Evaluating before
|
|
148
|
+
# ready is legal and follows the fallback chain.
|
|
149
|
+
def ready(timeout: 5)
|
|
150
|
+
ensure_worker
|
|
151
|
+
deadline = now + timeout
|
|
152
|
+
@ready_mutex.synchronize do
|
|
153
|
+
until @ready
|
|
154
|
+
left = deadline - now
|
|
155
|
+
break unless left.positive?
|
|
156
|
+
|
|
157
|
+
@ready_cond.wait(@ready_mutex, left)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
@stage
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# on_change registers a block called with the sorted keys of the flags
|
|
164
|
+
# whose value may have changed: after each accepted ruleset (through a
|
|
165
|
+
# segment, a declared attribute or unit type or a prerequisite as well
|
|
166
|
+
# as their own configuration) and on every local override change. It
|
|
167
|
+
# runs on the thread that caused the change; an exception is logged as
|
|
168
|
+
# listener_failed. Returns a callable that unregisters the block.
|
|
169
|
+
def on_change(&block) = listen(@change_listeners, block)
|
|
170
|
+
|
|
171
|
+
# on_stage registers a block called with the new stage on every
|
|
172
|
+
# transition. Returns a callable that unregisters it.
|
|
173
|
+
def on_stage(&block) = listen(@stage_listeners, block)
|
|
174
|
+
|
|
175
|
+
# recorder is the in-memory Recorder of an offline client, holding what
|
|
176
|
+
# it would have sent; nil online.
|
|
177
|
+
attr_reader :recorder
|
|
178
|
+
|
|
179
|
+
# local_overrides manages substitutions of flag values inside this
|
|
180
|
+
# process; see LocalOverrides.
|
|
181
|
+
def local_overrides = LocalOverrides.new(self)
|
|
182
|
+
|
|
183
|
+
# reload re-reads an offline client's document and swaps it in, firing
|
|
184
|
+
# on_change with the flags whose evaluation may have changed. It raises
|
|
185
|
+
# OfflineError when the client is not offline or the document fails;
|
|
186
|
+
# the held ruleset then stays.
|
|
187
|
+
def reload
|
|
188
|
+
raise OfflineError, "reload needs an offline client" unless @offline_source
|
|
189
|
+
|
|
190
|
+
changed = @reload_mutex.synchronize { load_offline }
|
|
191
|
+
fire_change(changed)
|
|
192
|
+
true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# exposure records that the unit described by context encountered the
|
|
196
|
+
# behaviour flag key serves: it looks up the most recent detail of key
|
|
197
|
+
# for context and sends an exposure only when it carries an experiment
|
|
198
|
+
# block; otherwise nothing is sent and exposure_skipped names why, at
|
|
199
|
+
# debug. A local override or a flag never evaluated with an experiment
|
|
200
|
+
# sends nothing. A repeat for the same experiment, unit and variation is
|
|
201
|
+
# suppressed for at least flush_interval seconds; the platform's own
|
|
202
|
+
# deduplication by event ID is what analysis relies on. It never raises.
|
|
203
|
+
def exposure(key, context: nil)
|
|
204
|
+
ensure_worker
|
|
205
|
+
@exposures&.exposure(context, key.to_s)
|
|
206
|
+
nil
|
|
207
|
+
rescue StandardError => e
|
|
208
|
+
log(:error, :evaluation_failed, flag: key.to_s, cause: "#{e.class}: #{e.message}")
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# alias records that two unit IDs belong to the same person, typically
|
|
213
|
+
# an anonymous ID and a user ID after sign-up. It changes no context:
|
|
214
|
+
# an experiment spanning sign-up keeps hashing on the unit type it
|
|
215
|
+
# declared, and the alias is what lets analysis join the two. It never
|
|
216
|
+
# raises.
|
|
217
|
+
def alias(from_unit_type, from_unit_id, to_unit_type, to_unit_id)
|
|
218
|
+
ensure_worker
|
|
219
|
+
@exposures&.add_alias(from_unit_type, from_unit_id, to_unit_type, to_unit_id)
|
|
220
|
+
nil
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
log(:error, :evaluation_failed, flag: "alias", cause: "#{e.class}: #{e.message}")
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# flush sends what is queued of exposures and aliases, then closes the
|
|
227
|
+
# window of evaluation counts and sends it as one or more evaluation
|
|
228
|
+
# summaries, waiting at most timeout seconds for the platform's answer.
|
|
229
|
+
# Nothing queued and nothing evaluated is nothing sent. An offline
|
|
230
|
+
# client hands both to its Recorder instead, at once. It never raises;
|
|
231
|
+
# events that fail stay queued and a window that fails merges into the
|
|
232
|
+
# next one.
|
|
233
|
+
def flush(timeout: 5)
|
|
234
|
+
do_flush(timeout)
|
|
235
|
+
true
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# close flushes the queued exposures and aliases and the current window
|
|
239
|
+
# of evaluation counts, then stops the stream and polling, all within
|
|
240
|
+
# timeout seconds: what the flush leaves of the timeout is what the
|
|
241
|
+
# background thread gets to end before it is killed. Getters keep
|
|
242
|
+
# serving the held ruleset.
|
|
243
|
+
def close(timeout: 5)
|
|
244
|
+
started = now
|
|
245
|
+
do_flush(timeout) unless @closed
|
|
246
|
+
worker = @fork_mutex.synchronize do
|
|
247
|
+
@closed = true
|
|
248
|
+
w = @worker
|
|
249
|
+
@worker = nil
|
|
250
|
+
w
|
|
251
|
+
end
|
|
252
|
+
if worker && @pid == Process.pid
|
|
253
|
+
worker.stop([timeout - (now - started), 0].max)
|
|
254
|
+
else
|
|
255
|
+
worker&.abandon
|
|
256
|
+
end
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# after_fork starts a forked child's background thread now rather than
|
|
261
|
+
# on the child's first call, so a worker that has not served a request
|
|
262
|
+
# yet already follows the change stream. It does nothing in the process
|
|
263
|
+
# that built the client and never raises. The Rails integration calls
|
|
264
|
+
# it from a fork hook.
|
|
265
|
+
def after_fork
|
|
266
|
+
ensure_worker unless @closed
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# type_of names the flag type of a Ruby value.
|
|
271
|
+
def self.type_of(value)
|
|
272
|
+
case value
|
|
273
|
+
when true, false then "boolean"
|
|
274
|
+
when String then "string"
|
|
275
|
+
when Numeric then "number"
|
|
276
|
+
else "json"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
private_class_method :type_of
|
|
280
|
+
|
|
281
|
+
private
|
|
282
|
+
|
|
283
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
284
|
+
|
|
285
|
+
def init_state
|
|
286
|
+
@mutex = Mutex.new
|
|
287
|
+
@fork_mutex = Mutex.new
|
|
288
|
+
@reload_mutex = Mutex.new
|
|
289
|
+
@listeners_mutex = Mutex.new
|
|
290
|
+
@change_listeners = {}
|
|
291
|
+
@stage_listeners = {}
|
|
292
|
+
@next_listener = 0
|
|
293
|
+
@ready_mutex = Mutex.new
|
|
294
|
+
@ready_cond = ConditionVariable.new
|
|
295
|
+
@ready = false
|
|
296
|
+
@loaded = nil
|
|
297
|
+
@stage = :built_in
|
|
298
|
+
@stale_since = nil
|
|
299
|
+
@overrides = {}.freeze
|
|
300
|
+
@once = {}
|
|
301
|
+
@unsupported_logged = {}
|
|
302
|
+
@pid = Process.pid
|
|
303
|
+
@closed = false
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def build_timing(poll_interval, request_timeout, flush_interval, summary_interval, overrides)
|
|
307
|
+
Timing.new(**DEFAULT_TIMING.to_h,
|
|
308
|
+
poll_connected: [poll_interval, MIN_POLL_INTERVAL].max,
|
|
309
|
+
request_timeout: request_timeout,
|
|
310
|
+
exposure_flush: flush_interval,
|
|
311
|
+
summary_flush: summary_interval,
|
|
312
|
+
**overrides.to_h).freeze
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def start_offline(document, path)
|
|
316
|
+
document = JSON.generate(document, max_nesting: false) if document.is_a?(Hash)
|
|
317
|
+
@offline_source =
|
|
318
|
+
if path then -> { File.binread(path) }
|
|
319
|
+
elsif document.respond_to?(:call) then document
|
|
320
|
+
else
|
|
321
|
+
-> { document }
|
|
322
|
+
end
|
|
323
|
+
@recorder = Recorder.new
|
|
324
|
+
build_reporters(nil, @recorder)
|
|
325
|
+
begin
|
|
326
|
+
load_offline
|
|
327
|
+
rescue OfflineError => e
|
|
328
|
+
log(:error, :fetch_failed, source: "offline", cause: e.message)
|
|
329
|
+
end
|
|
330
|
+
mark_ready
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def start_online(sdk_key, base_url, store:, stream:, platform:)
|
|
334
|
+
@stream_enabled = stream
|
|
335
|
+
@transport = Transport.new(sdk_key: sdk_key, base_url: base_url, platform: platform,
|
|
336
|
+
timeout: @timing.request_timeout)
|
|
337
|
+
build_reporters(@transport, nil)
|
|
338
|
+
@store = store && FileStore.new(store)
|
|
339
|
+
load_store if @store
|
|
340
|
+
@worker = Worker.new(self, @transport, @timing, stream: stream)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# build_reporters constructs the evaluation summary counter and the
|
|
344
|
+
# exposure and alias queue, unless send_summaries or send_exposures
|
|
345
|
+
# turned either off, in which case counting and every call are no-ops.
|
|
346
|
+
# Online they post through transport; offline they hand what they
|
|
347
|
+
# would have sent to recorder at once.
|
|
348
|
+
def build_reporters(transport, recorder)
|
|
349
|
+
if @send_summaries
|
|
350
|
+
@summaries = Summaries.new(transport: transport, timing: @timing, log: method(:log_hook),
|
|
351
|
+
record: recorder&.method(:record), app_version: @app_version,
|
|
352
|
+
ruleset_version: -> { @loaded&.ruleset&.version })
|
|
353
|
+
end
|
|
354
|
+
return unless @send_exposures
|
|
355
|
+
|
|
356
|
+
@exposures = Exposures.new(transport: transport, timing: @timing, log: method(:log_hook),
|
|
357
|
+
record: recorder&.method(:record_event), platform: @platform,
|
|
358
|
+
app_version: @app_version, wake: -> { @worker&.wake })
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# log_hook adapts Client#log's keyword arguments to the (level, event,
|
|
362
|
+
# fields) callable Exposures and Summaries expect.
|
|
363
|
+
def log_hook(level, event, fields) = log(level, event, **fields)
|
|
364
|
+
|
|
365
|
+
# exposures and summaries are read by Worker to drive their periodic
|
|
366
|
+
# flush; nil when either is off.
|
|
367
|
+
attr_reader :exposures, :summaries
|
|
368
|
+
|
|
369
|
+
# ensure_worker restarts the background thread in a forked child: the
|
|
370
|
+
# thread that ran in the parent does not exist there. The child's
|
|
371
|
+
# exposure and alias queue and evaluation summary window start empty;
|
|
372
|
+
# the parent, which never reaches restart_worker, keeps its own.
|
|
373
|
+
def ensure_worker
|
|
374
|
+
return if @pid == Process.pid
|
|
375
|
+
|
|
376
|
+
restart_worker
|
|
377
|
+
rescue StandardError => e
|
|
378
|
+
log(:error, :worker_failed, cause: "#{e.class}: #{e.message}")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def restart_worker
|
|
382
|
+
@fork_mutex.synchronize do
|
|
383
|
+
return if @pid == Process.pid
|
|
384
|
+
|
|
385
|
+
if @worker
|
|
386
|
+
@worker.abandon
|
|
387
|
+
@worker = Worker.new(self, @transport, @timing, stream: @stream_enabled)
|
|
388
|
+
end
|
|
389
|
+
@exposures&.reset_after_fork
|
|
390
|
+
@summaries&.reset_after_fork
|
|
391
|
+
@pid = Process.pid
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# do_flush sends the exposure and alias queue and the evaluation
|
|
396
|
+
# summary window at once, each on its own thread bounded by timeout,
|
|
397
|
+
# so one deadline covers both rather than a slow queue eating the
|
|
398
|
+
# window's share of it; both threads are always joined, whichever
|
|
399
|
+
# raises or times out first.
|
|
400
|
+
def do_flush(timeout)
|
|
401
|
+
ensure_worker
|
|
402
|
+
started = now
|
|
403
|
+
threads = [@exposures, @summaries].compact.map do |reporter|
|
|
404
|
+
Thread.new do
|
|
405
|
+
reporter.flush_once(timeout)
|
|
406
|
+
rescue StandardError => e
|
|
407
|
+
log(:error, :worker_failed, cause: "#{e.class}: #{e.message}")
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
threads.each { |t| t.join([timeout - (now - started), 0].max) }
|
|
411
|
+
rescue StandardError => e
|
|
412
|
+
log(:error, :worker_failed, cause: "#{e.class}: #{e.message}")
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# --- evaluation -------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
def serve(key, type, default, context, int: false)
|
|
418
|
+
ensure_worker
|
|
419
|
+
key = key.to_s
|
|
420
|
+
loaded = @loaded
|
|
421
|
+
detail, automatic = begin
|
|
422
|
+
served = evaluate(loaded, key, type, default, context)
|
|
423
|
+
int ? integral(loaded, key, default, *served) : served
|
|
424
|
+
rescue StandardError => e
|
|
425
|
+
log(:error, :evaluation_failed, flag: key, cause: "#{e.class}: #{e.message}")
|
|
426
|
+
[fallback(loaded, default, :general), false]
|
|
427
|
+
end
|
|
428
|
+
report(key, detail, automatic, context)
|
|
429
|
+
detail
|
|
430
|
+
rescue StandardError
|
|
431
|
+
Detail.new(value: default, reason: :fallback, error_code: :general, stage: @stage)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# report counts a served detail for the evaluation summary (and an
|
|
435
|
+
# offline client's recorder) and hands it to the exposure queue, which
|
|
436
|
+
# remembers an experiment block for a later exposure call and records
|
|
437
|
+
# the exposure at once for a flag with automatic exposure. A failure
|
|
438
|
+
# here never changes what was served.
|
|
439
|
+
def report(key, detail, automatic, context)
|
|
440
|
+
@recorder&.__send__(:count, key, detail)
|
|
441
|
+
@summaries&.count(key, detail)
|
|
442
|
+
@exposures&.evaluated(context, key, detail, automatic)
|
|
443
|
+
rescue StandardError => e
|
|
444
|
+
log(:error, :evaluation_failed, flag: key, cause: "#{e.class}: #{e.message}")
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# evaluate returns the served detail and whether the flag opts into
|
|
448
|
+
# automatic exposure: only meaningful together with an experiment
|
|
449
|
+
# block, which every early return here lacks.
|
|
450
|
+
def evaluate(loaded, key, type, default, context)
|
|
451
|
+
if (override = local_override(loaded, key))
|
|
452
|
+
return [mismatch(loaded, key, override[0], type, default), false] if override[0] != type
|
|
453
|
+
|
|
454
|
+
return [base(loaded, value: override[1], reason: :local_override), false]
|
|
455
|
+
end
|
|
456
|
+
return [fallback(nil, default, :no_ruleset), false] unless loaded
|
|
457
|
+
|
|
458
|
+
flag = loaded.ruleset.flag(key)
|
|
459
|
+
return [fallback(loaded, default, :flag_not_found), false] unless flag && TYPES.include?(flag.type)
|
|
460
|
+
return [mismatch(loaded, key, flag.type, type, default), false] if flag.type != type
|
|
461
|
+
|
|
462
|
+
result = loaded.ruleset.evaluate(key, context)
|
|
463
|
+
return [fallback(loaded, default, result.error_code), false] if result.reason == :error
|
|
464
|
+
return [fallback(loaded, default, :inconsistent_ruleset), false] unless value_of?(type, result.value)
|
|
465
|
+
|
|
466
|
+
detail = base(loaded, value: result.value, reason: result.reason, variation: result.variation,
|
|
467
|
+
rule_id: result.rule_id, overridden: result.override_influenced,
|
|
468
|
+
experiment: result.experiment)
|
|
469
|
+
[detail, flag.automatic_exposure]
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def base(loaded, **fields)
|
|
473
|
+
Detail.new(ruleset_version: loaded&.ruleset&.version, received_at: loaded&.received_at, stage: @stage, **fields)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def fallback(loaded, default, code)
|
|
477
|
+
base(loaded, value: default, reason: :fallback, error_code: code)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def mismatch(loaded, key, flag_type, requested, default)
|
|
481
|
+
once([:type_mismatch, key]) do
|
|
482
|
+
log(:warn, :type_mismatch, flag: key, flag_type: flag_type, requested_type: requested)
|
|
483
|
+
end
|
|
484
|
+
fallback(loaded, default, :type_mismatch)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# integral serves a number for the int getter as an Integer; one that is
|
|
488
|
+
# not whole is a type mismatch rather than a truncated value, and is
|
|
489
|
+
# counted as the fallback it serves.
|
|
490
|
+
def integral(loaded, key, default, detail, automatic)
|
|
491
|
+
return [detail, automatic] if detail.reason == :fallback
|
|
492
|
+
|
|
493
|
+
value = whole(detail.value)
|
|
494
|
+
return [mismatch(loaded, key, "number", "int", default), false] unless value
|
|
495
|
+
|
|
496
|
+
[Detail.new(**detail.to_h, value: value), automatic]
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# whole is a served number as an Integer, or nil when it is not whole.
|
|
500
|
+
def whole(value)
|
|
501
|
+
return value if value.is_a?(Integer)
|
|
502
|
+
return unless value.is_a?(Float) && value.finite? && value.to_r.denominator == 1
|
|
503
|
+
|
|
504
|
+
value.to_i
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def value_of?(type, value)
|
|
508
|
+
case type
|
|
509
|
+
when "boolean" then [true, false].include?(value)
|
|
510
|
+
when "string" then value.is_a?(String)
|
|
511
|
+
when "number" then value.is_a?(Numeric)
|
|
512
|
+
else true
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def once(key)
|
|
517
|
+
first = @mutex.synchronize { @once.key?(key) ? false : (@once[key] = true) }
|
|
518
|
+
yield if first
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# --- local overrides --------------------------------------------------
|
|
522
|
+
|
|
523
|
+
def local_override(loaded, key)
|
|
524
|
+
overrides = @overrides
|
|
525
|
+
return if overrides.empty? || loaded&.ruleset&.protected?
|
|
526
|
+
|
|
527
|
+
overrides[key]
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def set_local_override(key, value)
|
|
531
|
+
unless @allow_overrides
|
|
532
|
+
log(:warn, :override_refused, flag: key, reason: "not_allowed")
|
|
533
|
+
raise LocalOverridesNotAllowed, "local overrides need allow_overrides: true"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
ensure_worker
|
|
537
|
+
loaded = @loaded
|
|
538
|
+
if loaded&.ruleset&.protected?
|
|
539
|
+
log(:warn, :override_refused, flag: key, reason: "protected", environment: loaded.ruleset.environment.key)
|
|
540
|
+
raise LocalOverrideRefused, "local override of #{key.inspect} refused: the environment " \
|
|
541
|
+
"#{loaded.ruleset.environment.key.inspect} is protected"
|
|
542
|
+
end
|
|
543
|
+
flag = loaded&.ruleset&.flag(key)
|
|
544
|
+
type = flag ? flag.type : Client.__send__(:type_of, value)
|
|
545
|
+
normalized = LocalOverrides.__send__(:normalize, type, value)
|
|
546
|
+
@mutex.synchronize { @overrides = @overrides.merge(key => [type, normalized].freeze).freeze }
|
|
547
|
+
log(:warn, :override_set, flag: key)
|
|
548
|
+
fire_change([key])
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def remove_local_overrides(keys)
|
|
552
|
+
removed = @mutex.synchronize do
|
|
553
|
+
gone = keys.nil? ? @overrides.keys : keys & @overrides.keys
|
|
554
|
+
@overrides = @overrides.except(*gone).freeze
|
|
555
|
+
gone
|
|
556
|
+
end
|
|
557
|
+
fire_change(removed.sort)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def local_override_values = @overrides.transform_values { |(_, value)| value }
|
|
561
|
+
|
|
562
|
+
def refuse_local_overrides(ruleset)
|
|
563
|
+
return [] unless ruleset.protected?
|
|
564
|
+
|
|
565
|
+
dropped = @mutex.synchronize do
|
|
566
|
+
keys = @overrides.keys
|
|
567
|
+
@overrides = {}.freeze
|
|
568
|
+
keys
|
|
569
|
+
end
|
|
570
|
+
dropped.each do |k|
|
|
571
|
+
log(:warn, :override_refused, flag: k, reason: "protected", environment: ruleset.environment.key)
|
|
572
|
+
end
|
|
573
|
+
dropped
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# --- listeners and logging --------------------------------------------
|
|
577
|
+
|
|
578
|
+
def listen(listeners, block)
|
|
579
|
+
raise ArgumentError, "a block is required" unless block
|
|
580
|
+
|
|
581
|
+
id = @listeners_mutex.synchronize do
|
|
582
|
+
@next_listener += 1
|
|
583
|
+
listeners[@next_listener] = block
|
|
584
|
+
@next_listener
|
|
585
|
+
end
|
|
586
|
+
-> { @listeners_mutex.synchronize { listeners.delete(id) } && nil }
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def fire_change(keys)
|
|
590
|
+
fire(@change_listeners, keys.dup.freeze) unless keys.empty?
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def fire(listeners, argument)
|
|
594
|
+
@listeners_mutex.synchronize { listeners.values }.each do |listener|
|
|
595
|
+
listener.call(argument)
|
|
596
|
+
rescue StandardError => e
|
|
597
|
+
log(:error, :listener_failed, cause: "#{e.class}: #{e.message}")
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# log calls the logger; a logger that raises is ignored, so that no
|
|
602
|
+
# public method raises because of it.
|
|
603
|
+
def log(level, event, **fields)
|
|
604
|
+
@log.call(level, event, fields)
|
|
605
|
+
rescue StandardError
|
|
606
|
+
nil
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
end
|