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.
@@ -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