featureflip 2.13.1 → 2.14.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 +4 -4
- data/lib/featureflip/client.rb +7 -0
- data/lib/featureflip/config.rb +42 -1
- data/lib/featureflip/data_source/streaming.rb +5 -1
- data/lib/featureflip/shared_core.rb +324 -13
- data/lib/featureflip/snapshot.rb +256 -0
- data/lib/featureflip/store/flag_store.rb +4 -0
- data/lib/featureflip/version.rb +1 -1
- data/lib/featureflip.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2abe9cc6f772c5d01773b0c34ecd9de2323fb7f7228ebbe7806ca88967ce60c6
|
|
4
|
+
data.tar.gz: 37ba38ab6f4c4dd207119f6e292b0fa45131b721ab7c63616a7e26a7b0defc87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 140f40e9b708ae00f732e7d1df67558d000094f7000d0e521f3fef6f0f49155148123e96988edfc57840ba96cfa75097327768b26628a04bd359bfc5c71ed2e9
|
|
7
|
+
data.tar.gz: e8537c6c21a1b0143604b7f699849b8cf1065bc88f2fdad0cce65d199e96c604d1440d4304cf0efc129f47c559f35ca220a5721fd2446fa31253d610459344e9
|
data/lib/featureflip/client.rb
CHANGED
|
@@ -28,6 +28,13 @@ module Featureflip
|
|
|
28
28
|
@core.initialized?
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Where the flag data came from: a StartupInfo whose +source+ is "live" after a
|
|
32
|
+
# successful fetch, "snapshot" when started from the configured snapshot_store, or
|
|
33
|
+
# "none" (defaults served). It moves to "live" once the Evaluation API is reached.
|
|
34
|
+
def startup_info
|
|
35
|
+
@core.startup_info
|
|
36
|
+
end
|
|
37
|
+
|
|
31
38
|
def bool_variation(key, context, default_value)
|
|
32
39
|
return default_value if @closed
|
|
33
40
|
@core.bool_variation(key, context, default_value)
|
data/lib/featureflip/config.rb
CHANGED
|
@@ -13,6 +13,30 @@ module Featureflip
|
|
|
13
13
|
# than changing behavior.
|
|
14
14
|
attr_accessor :wrapper_name, :wrapper_version
|
|
15
15
|
|
|
16
|
+
# Opt-in last-known-config store (see Featureflip::FileSnapshotStore). When the
|
|
17
|
+
# initial fetch fails or times out the client starts from the stored snapshot
|
|
18
|
+
# instead of serving defaults. Honored on the first Client.get per SDK key. Not
|
|
19
|
+
# part of the different-config comparison; its identity is compared separately.
|
|
20
|
+
attr_accessor :snapshot_store
|
|
21
|
+
|
|
22
|
+
# How old a stored snapshot may be and still be used, in seconds. Must be a
|
|
23
|
+
# positive, finite number; there is no "never expire" value.
|
|
24
|
+
DEFAULT_MAX_SNAPSHOT_AGE_SECONDS = 86_400
|
|
25
|
+
UNSET = Object.new.freeze
|
|
26
|
+
private_constant :UNSET
|
|
27
|
+
attr_reader :max_snapshot_age_seconds
|
|
28
|
+
|
|
29
|
+
def max_snapshot_age_seconds=(value)
|
|
30
|
+
@max_snapshot_age_explicit = true
|
|
31
|
+
@max_snapshot_age_seconds = value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True when the caller passed a max age, so the "option ignored" warning can tell
|
|
35
|
+
# an omitted option from the default.
|
|
36
|
+
def max_snapshot_age_explicit?
|
|
37
|
+
@max_snapshot_age_explicit
|
|
38
|
+
end
|
|
39
|
+
|
|
16
40
|
# Evaluation inspectors -- callables invoked once per variation call with a
|
|
17
41
|
# Models::EvaluationEvent. Always an Array; non-callable entries are dropped
|
|
18
42
|
# on assignment rather than blowing up on the evaluation hot path.
|
|
@@ -33,7 +57,9 @@ module Featureflip
|
|
|
33
57
|
logger: nil,
|
|
34
58
|
inspectors: nil,
|
|
35
59
|
wrapper_name: nil,
|
|
36
|
-
wrapper_version: nil
|
|
60
|
+
wrapper_version: nil,
|
|
61
|
+
snapshot_store: nil,
|
|
62
|
+
max_snapshot_age_seconds: UNSET
|
|
37
63
|
)
|
|
38
64
|
@sdk_key = sdk_key
|
|
39
65
|
@base_url = base_url
|
|
@@ -50,6 +76,9 @@ module Featureflip
|
|
|
50
76
|
self.inspectors = inspectors
|
|
51
77
|
@wrapper_name = wrapper_name
|
|
52
78
|
@wrapper_version = wrapper_version
|
|
79
|
+
@snapshot_store = snapshot_store
|
|
80
|
+
@max_snapshot_age_explicit = !UNSET.equal?(max_snapshot_age_seconds)
|
|
81
|
+
@max_snapshot_age_seconds = @max_snapshot_age_explicit ? max_snapshot_age_seconds : DEFAULT_MAX_SNAPSHOT_AGE_SECONDS
|
|
53
82
|
|
|
54
83
|
validate!
|
|
55
84
|
end
|
|
@@ -76,6 +105,7 @@ module Featureflip
|
|
|
76
105
|
def validate!
|
|
77
106
|
finalize!
|
|
78
107
|
validate_positive_fields!
|
|
108
|
+
validate_snapshot_options!
|
|
79
109
|
end
|
|
80
110
|
|
|
81
111
|
private
|
|
@@ -93,6 +123,17 @@ module Featureflip
|
|
|
93
123
|
end
|
|
94
124
|
end
|
|
95
125
|
|
|
126
|
+
def validate_snapshot_options!
|
|
127
|
+
age = @max_snapshot_age_seconds
|
|
128
|
+
valid = age.is_a?(Numeric) && !age.is_a?(Complex) && age.to_f.finite? && age.positive?
|
|
129
|
+
unless valid
|
|
130
|
+
raise ConfigurationError, "max_snapshot_age_seconds must be a positive, finite number, got #{age.inspect}"
|
|
131
|
+
end
|
|
132
|
+
return if @snapshot_store.nil? || (@snapshot_store.respond_to?(:get) && @snapshot_store.respond_to?(:put))
|
|
133
|
+
|
|
134
|
+
raise ConfigurationError, "snapshot_store must respond to #get and #put"
|
|
135
|
+
end
|
|
136
|
+
|
|
96
137
|
def default_logger
|
|
97
138
|
if defined?(Rails) && Rails.respond_to?(:logger)
|
|
98
139
|
Rails.logger
|
|
@@ -36,7 +36,7 @@ module Featureflip
|
|
|
36
36
|
RECONNECT_BASE_DELAY_SECONDS = 1
|
|
37
37
|
MAX_BACKOFF_SECONDS = 30
|
|
38
38
|
|
|
39
|
-
def initialize(sdk_key:, config:, http_client:, on_flag_updated:, on_flag_deleted:, on_segment_updated:, on_error:, on_sync: nil, on_fallback_to_polling: nil, on_recovered: nil)
|
|
39
|
+
def initialize(sdk_key:, config:, http_client:, on_flag_updated:, on_flag_deleted:, on_segment_updated:, on_error:, on_sync: nil, on_fallback_to_polling: nil, on_recovered: nil, on_ping: nil)
|
|
40
40
|
@sdk_key = sdk_key
|
|
41
41
|
@config = config
|
|
42
42
|
@http_client = http_client
|
|
@@ -47,6 +47,7 @@ module Featureflip
|
|
|
47
47
|
@on_sync = on_sync
|
|
48
48
|
@on_fallback_to_polling = on_fallback_to_polling
|
|
49
49
|
@on_recovered = on_recovered
|
|
50
|
+
@on_ping = on_ping
|
|
50
51
|
@stop_flag = false
|
|
51
52
|
@thread = nil
|
|
52
53
|
@retry_count = 0
|
|
@@ -283,6 +284,9 @@ module Featureflip
|
|
|
283
284
|
# re-synced. Full replace, never a per-key merge.
|
|
284
285
|
flags, segments = @http_client.parse_flags_response(JSON.parse(data))
|
|
285
286
|
@on_sync&.call(flags, segments)
|
|
287
|
+
when "ping"
|
|
288
|
+
# The server's keep-alive: the stream is live and healthy.
|
|
289
|
+
@on_ping&.call
|
|
286
290
|
end
|
|
287
291
|
rescue UnevaluableEntityError => e
|
|
288
292
|
# Not a malformed payload: the frame was well-formed and simply described
|
|
@@ -45,6 +45,7 @@ module Featureflip
|
|
|
45
45
|
"Using existing configuration. Close all handles first to apply new config."
|
|
46
46
|
)
|
|
47
47
|
end
|
|
48
|
+
_warn_snapshot_options_ignored(existing._config, config)
|
|
48
49
|
return existing
|
|
49
50
|
else
|
|
50
51
|
# Stale entry — remove and replace
|
|
@@ -58,6 +59,22 @@ module Featureflip
|
|
|
58
59
|
end
|
|
59
60
|
end
|
|
60
61
|
|
|
62
|
+
# The snapshot store is honored on the first get per SDK key. A later get that
|
|
63
|
+
# passes a different store instance, or an explicit different max age, is told so.
|
|
64
|
+
# An omitted store or max age is not a request, so a wrapper that does not forward
|
|
65
|
+
# them never triggers the warning.
|
|
66
|
+
def self._warn_snapshot_options_ignored(existing, requested)
|
|
67
|
+
store_differs = !requested.snapshot_store.nil? && !requested.snapshot_store.equal?(existing.snapshot_store)
|
|
68
|
+
age_differs = requested.max_snapshot_age_explicit? &&
|
|
69
|
+
requested.max_snapshot_age_seconds != existing.max_snapshot_age_seconds
|
|
70
|
+
return unless store_differs || age_differs
|
|
71
|
+
|
|
72
|
+
requested.logger&.warn(
|
|
73
|
+
"Featureflip: snapshot_store/max_snapshot_age_seconds option ignored because a client " \
|
|
74
|
+
"for this SDK key already exists. Close all handles first to apply it."
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
61
78
|
def self._create_for_testing(flags)
|
|
62
79
|
core = allocate
|
|
63
80
|
core.send(:init_test_mode, flags)
|
|
@@ -75,9 +92,20 @@ module Featureflip
|
|
|
75
92
|
|
|
76
93
|
# --- Instance methods ---
|
|
77
94
|
|
|
78
|
-
|
|
95
|
+
# Wall and monotonic clocks, injectable so the refresh rule is testable without
|
|
96
|
+
# waiting minutes.
|
|
97
|
+
# @api private
|
|
98
|
+
Clock = Struct.new(:wall_ms, :monotonic)
|
|
99
|
+
SYSTEM_CLOCK = Clock.new(
|
|
100
|
+
-> { (Time.now.to_f * 1000).to_i },
|
|
101
|
+
-> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
102
|
+
).freeze
|
|
103
|
+
|
|
104
|
+
def initialize(sdk_key:, config:, clock: SYSTEM_CLOCK)
|
|
79
105
|
@sdk_key = sdk_key
|
|
80
106
|
@config = config
|
|
107
|
+
@clock = clock
|
|
108
|
+
init_snapshot_state
|
|
81
109
|
# Snapshot the (already-filtered) inspector list at construction: config is
|
|
82
110
|
# immutable-after-init from the core's point of view, so the evaluation path
|
|
83
111
|
# needs no locking. Deliberately excluded from _configs_equal -- callables
|
|
@@ -147,6 +175,12 @@ module Featureflip
|
|
|
147
175
|
@initialized
|
|
148
176
|
end
|
|
149
177
|
|
|
178
|
+
# Where the flag data came from: live, a stored snapshot, or nowhere. Moves to
|
|
179
|
+
# live on the first successful full live apply.
|
|
180
|
+
def startup_info
|
|
181
|
+
@startup || StartupInfo.new(Snapshot::SOURCE_LIVE)
|
|
182
|
+
end
|
|
183
|
+
|
|
150
184
|
# --- Evaluation methods ---
|
|
151
185
|
|
|
152
186
|
# The typed accessors declare the JSON type they expect so a mismatch degrades
|
|
@@ -298,6 +332,9 @@ module Featureflip
|
|
|
298
332
|
# would leave a restarted non-streaming core with no data source at all.
|
|
299
333
|
@streaming_handler = nil
|
|
300
334
|
@polling_handler = nil
|
|
335
|
+
# A forked child has no writer thread, so a slot the parent claimed would stay
|
|
336
|
+
# claimed forever and wedge persistence.
|
|
337
|
+
reset_write_slot
|
|
301
338
|
|
|
302
339
|
if @config.streaming
|
|
303
340
|
start_streaming
|
|
@@ -339,7 +376,7 @@ module Featureflip
|
|
|
339
376
|
end
|
|
340
377
|
@event_processor = nil
|
|
341
378
|
|
|
342
|
-
@config.logger&.info("Featureflip: core shut down
|
|
379
|
+
@config.logger&.info("Featureflip: core shut down")
|
|
343
380
|
end
|
|
344
381
|
|
|
345
382
|
def bootstrap!
|
|
@@ -349,18 +386,118 @@ module Featureflip
|
|
|
349
386
|
start_event_processor if @config.send_events
|
|
350
387
|
end
|
|
351
388
|
|
|
389
|
+
# Without a snapshot store a failure raises InitializationError, as before. With
|
|
390
|
+
# one, the failure falls back to the stored snapshot (or defaults) and init
|
|
391
|
+
# completes; the data source started afterwards keeps trying and recovers.
|
|
352
392
|
def fetch_initial_flags
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
393
|
+
# Only the fetch is under the timeout. Applying inside it would let a timeout that
|
|
394
|
+
# lands after a successful fetch fall back to a snapshot over live data.
|
|
395
|
+
flags, segments = Timeout.timeout(@config.init_timeout) { @http_client.get_flags }
|
|
396
|
+
# Establishes the baseline; fires nothing (the store has no snapshot yet).
|
|
397
|
+
apply_snapshot(flags, segments)
|
|
398
|
+
@initialized = true
|
|
399
|
+
rescue Timeout::Error => e
|
|
400
|
+
if @config.snapshot_store.nil?
|
|
401
|
+
raise InitializationError, "Initialization timed out after #{@config.init_timeout}s"
|
|
357
402
|
end
|
|
358
|
-
|
|
359
|
-
|
|
403
|
+
|
|
404
|
+
fall_back_to_snapshot(e, timed_out: true)
|
|
360
405
|
rescue InitializationError
|
|
361
406
|
raise
|
|
362
407
|
rescue StandardError => e
|
|
363
|
-
raise InitializationError, "Failed to initialize: #{e.message}"
|
|
408
|
+
raise InitializationError, "Failed to initialize: #{e.message}" if @config.snapshot_store.nil?
|
|
409
|
+
|
|
410
|
+
fall_back_to_snapshot(e, timed_out: false)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def fall_back_to_snapshot(error, timed_out:)
|
|
414
|
+
load_snapshot_fallback(describe_init_failure(error, timed_out))
|
|
415
|
+
@initialized = true
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# A short, key-free reason the initial fetch failed. A revoked key (HTTP 401/403)
|
|
419
|
+
# must read differently from an unreachable host.
|
|
420
|
+
def describe_init_failure(error, timed_out)
|
|
421
|
+
return "timed out after #{@config.init_timeout}s" if timed_out
|
|
422
|
+
return "HTTP #{error.status}" if error.respond_to?(:status) && error.status.is_a?(Integer)
|
|
423
|
+
|
|
424
|
+
message = error.message.to_s
|
|
425
|
+
message.empty? ? error.class.name : message
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Read the store on a thread, bounded by init_timeout. Returns [finished, value].
|
|
429
|
+
# A raising store is a miss (warned). A read still running at the deadline is
|
|
430
|
+
# abandoned and its late result ignored.
|
|
431
|
+
def read_snapshot_store(cause)
|
|
432
|
+
store = @config.snapshot_store
|
|
433
|
+
box = {}
|
|
434
|
+
reader = Thread.new do
|
|
435
|
+
box[:value] = store.get
|
|
436
|
+
rescue StandardError, ScriptError => e
|
|
437
|
+
box[:error] = e
|
|
438
|
+
end
|
|
439
|
+
reader.report_on_exception = false
|
|
440
|
+
unless reader.join(@config.init_timeout)
|
|
441
|
+
@config.logger&.warn(
|
|
442
|
+
"Featureflip: initial flag fetch failed (#{cause}) and the snapshot store read timed out " \
|
|
443
|
+
"after #{@config.init_timeout}s; serving defaults"
|
|
444
|
+
)
|
|
445
|
+
return [false, nil]
|
|
446
|
+
end
|
|
447
|
+
if box.key?(:error)
|
|
448
|
+
@config.logger&.warn(
|
|
449
|
+
"Featureflip: initial flag fetch failed (#{cause}) and the snapshot store read failed: " \
|
|
450
|
+
"#{box[:error].message}; serving defaults"
|
|
451
|
+
)
|
|
452
|
+
return [true, nil]
|
|
453
|
+
end
|
|
454
|
+
[true, box[:value]]
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Serve the stored snapshot after a failed initial fetch. Never raises. Applied
|
|
458
|
+
# without persisting, so loading it does not refresh its own fetchedAt. The store
|
|
459
|
+
# hands back opaque text; every check and every reason is decided here.
|
|
460
|
+
def load_snapshot_fallback(cause)
|
|
461
|
+
finished, value = read_snapshot_store(cause)
|
|
462
|
+
return unless finished # the read already warned
|
|
463
|
+
|
|
464
|
+
if value.nil? # a plain miss: no reason
|
|
465
|
+
@config.logger&.warn(
|
|
466
|
+
"Featureflip: initial flag fetch failed (#{cause}); no persisted snapshot; serving defaults"
|
|
467
|
+
)
|
|
468
|
+
return
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
now = @clock.wall_ms.call
|
|
472
|
+
begin
|
|
473
|
+
decoded = Snapshot.decode_envelope(value, @key_hash, now)
|
|
474
|
+
rescue Snapshot::InvalidError => e
|
|
475
|
+
reject_snapshot(cause, Snapshot::REASON_INVALID, e.why)
|
|
476
|
+
return
|
|
477
|
+
end
|
|
478
|
+
age_ms = [0, now - decoded.fetched_at_ms].max
|
|
479
|
+
age_seconds = age_ms / 1000
|
|
480
|
+
limit = @config.max_snapshot_age_seconds
|
|
481
|
+
if age_ms > limit * 1000
|
|
482
|
+
reject_snapshot(cause, Snapshot::REASON_EXPIRED, "#{age_seconds}s old, limit #{limit}s", age_seconds)
|
|
483
|
+
return
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
@store.init(decoded.flags, decoded.segments)
|
|
487
|
+
@startup = StartupInfo.new(Snapshot::SOURCE_SNAPSHOT, Snapshot::REASON_SNAPSHOT, age_seconds)
|
|
488
|
+
@config.logger&.warn(
|
|
489
|
+
"Featureflip: initial flag fetch failed (#{cause}); serving persisted snapshot (#{age_seconds}s old)"
|
|
490
|
+
)
|
|
491
|
+
rescue StandardError => e
|
|
492
|
+
@config.logger&.warn("Featureflip: snapshot load failed: #{e.class}: #{e.message}")
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def reject_snapshot(cause, reason, why, age_seconds = nil)
|
|
496
|
+
@startup = StartupInfo.new(Snapshot::SOURCE_NONE, reason, age_seconds)
|
|
497
|
+
@config.logger&.warn(
|
|
498
|
+
"Featureflip: initial flag fetch failed (#{cause}); ignoring persisted snapshot " \
|
|
499
|
+
"(#{reason}: #{why}); serving defaults"
|
|
500
|
+
)
|
|
364
501
|
end
|
|
365
502
|
|
|
366
503
|
def start_data_source
|
|
@@ -382,6 +519,7 @@ module Featureflip
|
|
|
382
519
|
on_flag_deleted: ->(key) { apply_flag_delete(key) },
|
|
383
520
|
on_segment_updated: ->(flags, segments) { apply_snapshot(flags, segments) },
|
|
384
521
|
on_sync: ->(flags, segments) { apply_snapshot(flags, segments) },
|
|
522
|
+
on_ping: -> { on_streaming_ping },
|
|
385
523
|
on_error: ->(_err) { },
|
|
386
524
|
on_fallback_to_polling: -> { fallback_to_polling },
|
|
387
525
|
on_recovered: -> { stop_fallback_polling }
|
|
@@ -461,15 +599,185 @@ module Featureflip
|
|
|
461
599
|
# Every data-source write funnels through one of these three so the change
|
|
462
600
|
# set is computed by the store and reported in one place.
|
|
463
601
|
def apply_snapshot(flags, segments)
|
|
464
|
-
|
|
602
|
+
changed = @store.init(flags, segments)
|
|
603
|
+
@startup = StartupInfo.new(Snapshot::SOURCE_LIVE)
|
|
604
|
+
persist_full
|
|
605
|
+
notify_update_listeners(changed)
|
|
465
606
|
end
|
|
466
607
|
|
|
467
608
|
def apply_flag_update(flag)
|
|
468
|
-
|
|
609
|
+
changed = @store.upsert(flag)
|
|
610
|
+
persist_delta
|
|
611
|
+
notify_update_listeners(changed)
|
|
469
612
|
end
|
|
470
613
|
|
|
471
614
|
def apply_flag_delete(key)
|
|
472
|
-
|
|
615
|
+
changed = @store.remove_flag(key)
|
|
616
|
+
persist_delta
|
|
617
|
+
notify_update_listeners(changed)
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# --- Snapshot persistence ---
|
|
621
|
+
|
|
622
|
+
def init_snapshot_state
|
|
623
|
+
@startup = StartupInfo.new(Snapshot::SOURCE_NONE)
|
|
624
|
+
@key_hash = Snapshot.compute_key_hash(@config.base_url, @sdk_key)
|
|
625
|
+
@snapshot_mutex = Mutex.new
|
|
626
|
+
@persist_in_flight = false
|
|
627
|
+
@persist_queued = false
|
|
628
|
+
@last_persist_at = nil
|
|
629
|
+
@last_persisted_data = nil
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def snapshot_store
|
|
633
|
+
@config.snapshot_store
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def snapshot_stopped?
|
|
637
|
+
@closed || @shut_down
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def within_refresh_window?
|
|
641
|
+
last = @snapshot_mutex.synchronize { @last_persist_at }
|
|
642
|
+
!last.nil? && @clock.monotonic.call - last < Snapshot::REFRESH_INTERVAL_SECONDS
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def serialize_state
|
|
646
|
+
Snapshot.serialize_flags_payload(@store.all_flags, @store.all_segments)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# After a single-flag delta, only once live: never right after a snapshot or
|
|
650
|
+
# none start, or stale data would get a fresh fetchedAt.
|
|
651
|
+
def persist_delta
|
|
652
|
+
persist if !snapshot_store.nil? && @startup.source == Snapshot::SOURCE_LIVE
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# After a full live apply. Changed content is written at once. An unchanged state
|
|
656
|
+
# is only rewritten once the last write is a refresh interval old.
|
|
657
|
+
def persist_full
|
|
658
|
+
return if snapshot_store.nil? || snapshot_stopped?
|
|
659
|
+
|
|
660
|
+
if within_refresh_window?
|
|
661
|
+
begin
|
|
662
|
+
data = serialize_state
|
|
663
|
+
rescue StandardError => e
|
|
664
|
+
@config.logger&.warn("Featureflip: snapshot persist failed: #{e.class}: #{e.message}")
|
|
665
|
+
return
|
|
666
|
+
end
|
|
667
|
+
return if @snapshot_mutex.synchronize { data == @last_persisted_data }
|
|
668
|
+
end
|
|
669
|
+
persist
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# The stream heartbeat: while live, refresh the stored fetchedAt at most once per
|
|
673
|
+
# refresh interval, so a healthy streaming client does not let its snapshot age out.
|
|
674
|
+
def on_streaming_ping
|
|
675
|
+
return if snapshot_store.nil? || @startup.source != Snapshot::SOURCE_LIVE
|
|
676
|
+
return if within_refresh_window?
|
|
677
|
+
|
|
678
|
+
persist
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Write the full state without blocking evaluation or init: at most one put in
|
|
682
|
+
# flight plus one trailing write, which reads the state when it runs.
|
|
683
|
+
def persist
|
|
684
|
+
return if snapshot_store.nil? || snapshot_stopped?
|
|
685
|
+
|
|
686
|
+
claimed = @snapshot_mutex.synchronize do
|
|
687
|
+
if @persist_in_flight
|
|
688
|
+
@persist_queued = true
|
|
689
|
+
false
|
|
690
|
+
else
|
|
691
|
+
@persist_in_flight = true
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
return unless claimed
|
|
695
|
+
|
|
696
|
+
@writer_started = false
|
|
697
|
+
begin
|
|
698
|
+
text = build_snapshot_text || finish_persist
|
|
699
|
+
if text.nil?
|
|
700
|
+
@writer_started = true # finish_persist already released the slot
|
|
701
|
+
return
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
start_write_thread(text)
|
|
705
|
+
rescue StandardError, ScriptError => e
|
|
706
|
+
# Can't start a thread. Don't leave the in-flight flag set, or every later
|
|
707
|
+
# write is wedged, and never let this escape into an apply.
|
|
708
|
+
@config.logger&.warn("Featureflip: snapshot persist failed: #{e.class}: #{e.message}")
|
|
709
|
+
ensure
|
|
710
|
+
# Also covers an Exception that is not a StandardError (a Timeout or stream
|
|
711
|
+
# stop landing here), which propagates after the slot is released.
|
|
712
|
+
reset_write_slot unless @writer_started
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def reset_write_slot
|
|
717
|
+
@snapshot_mutex.synchronize do
|
|
718
|
+
@persist_in_flight = false
|
|
719
|
+
@persist_queued = false
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def start_write_thread(text)
|
|
724
|
+
thread = Thread.new { persist_worker(text) }
|
|
725
|
+
# Marked the instant the thread exists: from here the writer owns the slot, so an
|
|
726
|
+
# exception landing later must not release it (that would allow a second writer).
|
|
727
|
+
@writer_started = true
|
|
728
|
+
silence_thread_report(thread)
|
|
729
|
+
thread
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def silence_thread_report(thread)
|
|
733
|
+
thread.report_on_exception = false
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# The envelope for the current state, stamped now. Never raises.
|
|
737
|
+
def build_snapshot_text
|
|
738
|
+
fetched_at = @clock.wall_ms.call
|
|
739
|
+
data = serialize_state
|
|
740
|
+
@snapshot_mutex.synchronize do
|
|
741
|
+
@last_persist_at = @clock.monotonic.call
|
|
742
|
+
@last_persisted_data = data
|
|
743
|
+
end
|
|
744
|
+
Snapshot.encode_envelope(@key_hash, fetched_at, data)
|
|
745
|
+
rescue StandardError => e
|
|
746
|
+
@config.logger&.warn("Featureflip: snapshot persist failed: #{e.class}: #{e.message}")
|
|
747
|
+
nil
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# End one write. Returns the next envelope if a trailing write is due. No trailing
|
|
751
|
+
# write starts once the core is shut down.
|
|
752
|
+
def finish_persist
|
|
753
|
+
loop do
|
|
754
|
+
proceed = @snapshot_mutex.synchronize do
|
|
755
|
+
if @persist_queued && !snapshot_stopped?
|
|
756
|
+
@persist_queued = false
|
|
757
|
+
true
|
|
758
|
+
else
|
|
759
|
+
@persist_queued = false
|
|
760
|
+
@persist_in_flight = false
|
|
761
|
+
false
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
return nil unless proceed
|
|
765
|
+
|
|
766
|
+
text = build_snapshot_text
|
|
767
|
+
return text unless text.nil?
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
def persist_worker(text)
|
|
772
|
+
store = snapshot_store
|
|
773
|
+
until text.nil?
|
|
774
|
+
begin
|
|
775
|
+
store.put(text)
|
|
776
|
+
rescue StandardError, ScriptError => e
|
|
777
|
+
@config.logger&.warn("Featureflip: snapshot store write failed: #{e.class}: #{e.message}")
|
|
778
|
+
end
|
|
779
|
+
text = finish_persist
|
|
780
|
+
end
|
|
473
781
|
end
|
|
474
782
|
|
|
475
783
|
# Runs on the streaming/polling thread, OUTSIDE the store lock: listeners are
|
|
@@ -601,6 +909,9 @@ module Featureflip
|
|
|
601
909
|
def init_test_mode(flags)
|
|
602
910
|
@sdk_key = "test-key"
|
|
603
911
|
@config = Config.new
|
|
912
|
+
@clock = SYSTEM_CLOCK
|
|
913
|
+
init_snapshot_state
|
|
914
|
+
@startup = StartupInfo.new(Snapshot::SOURCE_LIVE)
|
|
604
915
|
@inspectors = []
|
|
605
916
|
@store = Store::FlagStore.new
|
|
606
917
|
@evaluator = Evaluation::Evaluator.new(logger: @config.logger)
|
|
@@ -633,6 +944,6 @@ module Featureflip
|
|
|
633
944
|
a.send_events == b.send_events
|
|
634
945
|
end
|
|
635
946
|
|
|
636
|
-
private_class_method :_configs_equal
|
|
947
|
+
private_class_method :_configs_equal, :_warn_snapshot_options_ignored
|
|
637
948
|
end
|
|
638
949
|
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require "logger"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Featureflip
|
|
7
|
+
# Where the client's flag data came from at startup.
|
|
8
|
+
#
|
|
9
|
+
# source: Snapshot::SOURCE_LIVE ("live") after a successful fetch from the
|
|
10
|
+
# Evaluation API, Snapshot::SOURCE_SNAPSHOT ("snapshot") when started from the
|
|
11
|
+
# snapshot store, Snapshot::SOURCE_NONE ("none") when there is no data yet
|
|
12
|
+
# (caller defaults are served).
|
|
13
|
+
# reason: "snapshot" for a snapshot start, "snapshot-expired" or
|
|
14
|
+
# "snapshot-invalid" when a stored snapshot was ignored. nil once the client is
|
|
15
|
+
# live, or when nothing was stored.
|
|
16
|
+
# snapshot_age_seconds: age of the snapshot that was used or rejected as
|
|
17
|
+
# expired, in whole seconds.
|
|
18
|
+
StartupInfo = Struct.new(:source, :reason, :snapshot_age_seconds) do
|
|
19
|
+
def initialize(source, reason = nil, snapshot_age_seconds = nil)
|
|
20
|
+
super
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Opaque text store for the last good flag snapshot (a duck type, not a base
|
|
26
|
+
# class). Implement two methods:
|
|
27
|
+
#
|
|
28
|
+
# get -> the String last stored, or nil if nothing is stored
|
|
29
|
+
# put(text) -> store the String, replacing any previous value
|
|
30
|
+
#
|
|
31
|
+
# Both may be called from a background thread. A store that raises is handled: a
|
|
32
|
+
# failing get is a miss and a failing put is a dropped write, each with a logged
|
|
33
|
+
# warning. The SDK owns the stored text end to end (envelope, environment check,
|
|
34
|
+
# age check and every startup reason), so one store instance may safely be given
|
|
35
|
+
# to several SDK keys.
|
|
36
|
+
#
|
|
37
|
+
# Everything on Snapshot except the constants is internal (@api private): the
|
|
38
|
+
# public surface is the store contract above, FileSnapshotStore, StartupInfo and
|
|
39
|
+
# Config#snapshot_store.
|
|
40
|
+
module Snapshot
|
|
41
|
+
# StartupInfo#source values.
|
|
42
|
+
SOURCE_LIVE = "live".freeze
|
|
43
|
+
SOURCE_SNAPSHOT = "snapshot".freeze
|
|
44
|
+
SOURCE_NONE = "none".freeze
|
|
45
|
+
|
|
46
|
+
# StartupInfo#reason values.
|
|
47
|
+
REASON_SNAPSHOT = "snapshot".freeze
|
|
48
|
+
REASON_EXPIRED = "snapshot-expired".freeze
|
|
49
|
+
REASON_INVALID = "snapshot-invalid".freeze
|
|
50
|
+
|
|
51
|
+
# How far in the future a stored fetchedAt may be (clock skew between the writer
|
|
52
|
+
# and the reader) and still be accepted, with its age clamped to 0.
|
|
53
|
+
FUTURE_FETCHED_AT_TOLERANCE_MS = 60_000
|
|
54
|
+
|
|
55
|
+
# While live, an unchanged state is rewritten (refreshing fetchedAt) at most
|
|
56
|
+
# this often. Changed content is never throttled.
|
|
57
|
+
REFRESH_INTERVAL_SECONDS = 300.0
|
|
58
|
+
|
|
59
|
+
# Stored text failed a check. +why+ names the check, never the contents or key.
|
|
60
|
+
class InvalidError < StandardError
|
|
61
|
+
attr_reader :why
|
|
62
|
+
|
|
63
|
+
def initialize(why)
|
|
64
|
+
super(why)
|
|
65
|
+
@why = why
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Decoded = Struct.new(:flags, :segments, :fetched_at_ms, keyword_init: true)
|
|
70
|
+
|
|
71
|
+
NullConfig = Struct.new(:logger, :base_url)
|
|
72
|
+
private_constant :NullConfig
|
|
73
|
+
|
|
74
|
+
# Identify the (base URL, SDK key) pair without storing the key.
|
|
75
|
+
def self.compute_key_hash(base_url, sdk_key)
|
|
76
|
+
Digest::SHA256.hexdigest("#{base_url.to_s.sub(%r{/+\z}, "")}\n#{sdk_key}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The stored text, from an already serialized +data+.
|
|
80
|
+
def self.encode_envelope(key_hash, fetched_at_ms, data_text)
|
|
81
|
+
"{\"v\":1,\"keyHash\":#{JSON.generate(key_hash)},\"fetchedAt\":#{Integer(fetched_at_ms)},\"data\":#{data_text}}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Parse and check stored text. Age is not checked here.
|
|
85
|
+
# Raises InvalidError for any failed check, in the pinned order.
|
|
86
|
+
def self.decode_envelope(text, key_hash, now_ms)
|
|
87
|
+
raise InvalidError, "store returned a non-string value" unless text.is_a?(String)
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
env = JSON.parse(text)
|
|
91
|
+
rescue StandardError
|
|
92
|
+
raise InvalidError, text.empty? ? "empty text" : "not valid JSON"
|
|
93
|
+
end
|
|
94
|
+
raise InvalidError, "not a snapshot envelope" unless env.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
version = env["v"]
|
|
97
|
+
unless version.is_a?(Numeric) && version == 1
|
|
98
|
+
raise InvalidError, "unsupported envelope version"
|
|
99
|
+
end
|
|
100
|
+
raise InvalidError, "written for a different SDK key or base URL" unless env["keyHash"] == key_hash
|
|
101
|
+
|
|
102
|
+
fetched_at = positive_integer(env["fetchedAt"])
|
|
103
|
+
raise InvalidError, "fetchedAt is not a positive integer" if fetched_at.nil?
|
|
104
|
+
raise InvalidError, "fetchedAt is in the future" if fetched_at > now_ms + FUTURE_FETCHED_AT_TOLERANCE_MS
|
|
105
|
+
|
|
106
|
+
data = env["data"]
|
|
107
|
+
raise InvalidError, "missing data" unless data.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
begin
|
|
110
|
+
raise ArgumentError, "flags and segments must be arrays" unless data["flags"].is_a?(Array) && data["segments"].is_a?(Array)
|
|
111
|
+
|
|
112
|
+
flags, segments = payload_parser.parse_flags_response(data)
|
|
113
|
+
rescue StandardError
|
|
114
|
+
raise InvalidError, "data failed payload validation"
|
|
115
|
+
end
|
|
116
|
+
Decoded.new(flags: flags, segments: segments, fetched_at_ms: fetched_at)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.positive_integer(value)
|
|
120
|
+
case value
|
|
121
|
+
when Integer then value.positive? ? value : nil
|
|
122
|
+
when Float then value.finite? && value == value.floor && value.positive? ? value.to_i : nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The SDK's own live-payload parser (so the same wholesale-discard validation
|
|
127
|
+
# and unevaluable-entity drops apply), with logging off.
|
|
128
|
+
def self.payload_parser
|
|
129
|
+
Http::Client.new(nil, NullConfig.new(nil, ""))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# --- Serialization (in-memory state back to the /v1/sdk/flags wire shape) ---
|
|
133
|
+
#
|
|
134
|
+
# The models hold the wire spelling already (Boolean/Fixed/Rollout, And/Or, ...;
|
|
135
|
+
# an unknown value is kept verbatim), so this only renames fields. Flags and
|
|
136
|
+
# segments are sorted by key and the property order is fixed: the unchanged-state
|
|
137
|
+
# comparison relies on a deterministic text.
|
|
138
|
+
|
|
139
|
+
def self.serialize_flags_payload(flags, segments)
|
|
140
|
+
JSON.generate(
|
|
141
|
+
"flags" => flags.sort_by { |f| f.key.to_s }.map { |f| wire_flag(f) },
|
|
142
|
+
"segments" => segments.sort_by { |s| s.key.to_s }.map { |s| wire_segment(s) }
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.wire_condition(c)
|
|
147
|
+
{ "attribute" => c.attribute, "operator" => c.operator, "values" => c.values, "negate" => c.negate }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.wire_group(g)
|
|
151
|
+
{ "operator" => g.operator, "conditions" => g.conditions.map { |c| wire_condition(c) } }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.wire_serve(s)
|
|
155
|
+
out = { "type" => s.type }
|
|
156
|
+
out["variation"] = s.variation unless s.variation.nil?
|
|
157
|
+
out["bucketBy"] = s.bucket_by unless s.bucket_by.nil?
|
|
158
|
+
out["salt"] = s.salt unless s.salt.nil?
|
|
159
|
+
unless s.variations.nil?
|
|
160
|
+
out["variations"] = s.variations.map { |v| { "key" => v.key, "weight" => v.weight } }
|
|
161
|
+
end
|
|
162
|
+
out
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.wire_rule(r)
|
|
166
|
+
out = {
|
|
167
|
+
"id" => r.id,
|
|
168
|
+
"priority" => r.priority,
|
|
169
|
+
"conditionGroups" => r.condition_groups.map { |g| wire_group(g) },
|
|
170
|
+
"serve" => wire_serve(r.serve)
|
|
171
|
+
}
|
|
172
|
+
out["segmentKey"] = r.segment_key unless r.segment_key.nil?
|
|
173
|
+
out
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.wire_flag(f)
|
|
177
|
+
{
|
|
178
|
+
"key" => f.key,
|
|
179
|
+
"version" => f.version,
|
|
180
|
+
"type" => f.type,
|
|
181
|
+
"enabled" => f.enabled,
|
|
182
|
+
"variations" => f.variations.map { |v| { "key" => v.key, "value" => v.value } },
|
|
183
|
+
"rules" => f.rules.map { |r| wire_rule(r) },
|
|
184
|
+
"fallthrough" => wire_serve(f.fallthrough),
|
|
185
|
+
"offVariation" => f.off_variation,
|
|
186
|
+
"prerequisites" => f.prerequisites.map do |p|
|
|
187
|
+
{ "prerequisiteFlagKey" => p.prerequisite_flag_key, "expectedVariationKey" => p.expected_variation_key }
|
|
188
|
+
end
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def self.wire_segment(s)
|
|
193
|
+
{
|
|
194
|
+
"key" => s.key,
|
|
195
|
+
"version" => s.version,
|
|
196
|
+
"conditions" => s.conditions.map { |c| wire_condition(c) },
|
|
197
|
+
"conditionLogic" => s.condition_logic
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private_class_method :positive_integer, :payload_parser, :wire_condition, :wire_group,
|
|
202
|
+
:wire_serve, :wire_rule, :wire_flag, :wire_segment
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Persist the snapshot text to one file (mode 0600), written atomically.
|
|
206
|
+
#
|
|
207
|
+
# The store is opaque: it neither parses nor checks what it holds, so one instance
|
|
208
|
+
# can be shared by several SDK keys. It never raises: a failure is logged and
|
|
209
|
+
# treated as a miss (get) or a dropped write (put). The parent directory must
|
|
210
|
+
# already exist; it is never created.
|
|
211
|
+
#
|
|
212
|
+
# There is deliberately no fsync before the rename, so a power loss can leave a
|
|
213
|
+
# zero-length or partial file. The SDK reads that as "snapshot-invalid", logs a
|
|
214
|
+
# warning and serves defaults, and the next successful write replaces it.
|
|
215
|
+
#
|
|
216
|
+
# A write runs on a background thread, so a process killed mid-write can leave a
|
|
217
|
+
# "<name>.<pid>.<hex>.tmp" file next to the snapshot. It is never read.
|
|
218
|
+
#
|
|
219
|
+
# Owner-only permissions are POSIX semantics; on Windows the file mode bits are
|
|
220
|
+
# not enforced and the file inherits the directory's access control list.
|
|
221
|
+
class FileSnapshotStore
|
|
222
|
+
def initialize(path, logger: nil)
|
|
223
|
+
@path = path.to_s
|
|
224
|
+
@logger = logger || ::Logger.new($stderr, level: ::Logger::WARN)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def get
|
|
228
|
+
File.binread(@path).force_encoding(Encoding::UTF_8).scrub
|
|
229
|
+
rescue Errno::ENOENT
|
|
230
|
+
nil
|
|
231
|
+
rescue StandardError => e
|
|
232
|
+
@logger.warn("Featureflip: snapshot file read failed (#{@path}): #{e.class}: #{e.message}")
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def put(text)
|
|
237
|
+
tmp = "#{@path}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp"
|
|
238
|
+
begin
|
|
239
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |f|
|
|
240
|
+
f.binmode
|
|
241
|
+
f.write(text.to_s.encode(Encoding::UTF_8))
|
|
242
|
+
f.flush
|
|
243
|
+
end
|
|
244
|
+
File.rename(tmp, @path)
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
@logger.warn("Featureflip: snapshot file write failed (#{@path}): #{e.class}: #{e.message}")
|
|
247
|
+
begin
|
|
248
|
+
File.unlink(tmp)
|
|
249
|
+
rescue StandardError
|
|
250
|
+
# nothing left to clean up
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
data/lib/featureflip/version.rb
CHANGED
data/lib/featureflip.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative "featureflip/evaluation/evaluator"
|
|
|
12
12
|
require_relative "featureflip/store/update_diff"
|
|
13
13
|
require_relative "featureflip/store/flag_store"
|
|
14
14
|
require_relative "featureflip/http/client"
|
|
15
|
+
require_relative "featureflip/snapshot"
|
|
15
16
|
require_relative "featureflip/events/event"
|
|
16
17
|
require_relative "featureflip/events/event_processor"
|
|
17
18
|
require_relative "featureflip/data_source/streaming"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: featureflip
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Featureflip
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|
|
@@ -93,6 +93,7 @@ files:
|
|
|
93
93
|
- lib/featureflip/models/segment.rb
|
|
94
94
|
- lib/featureflip/sdk_identity.rb
|
|
95
95
|
- lib/featureflip/shared_core.rb
|
|
96
|
+
- lib/featureflip/snapshot.rb
|
|
96
97
|
- lib/featureflip/store/flag_store.rb
|
|
97
98
|
- lib/featureflip/store/update_diff.rb
|
|
98
99
|
- lib/featureflip/version.rb
|