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,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