featureflip 2.13.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c41498d43abe3f592e70ef93178a39bbabfc481072cf1862b3a18399ad3972db
4
- data.tar.gz: 00b9495f23ece99027e3c03c647616cd240a11338f8376511ac4bbd2ce46e826
3
+ metadata.gz: 2abe9cc6f772c5d01773b0c34ecd9de2323fb7f7228ebbe7806ca88967ce60c6
4
+ data.tar.gz: 37ba38ab6f4c4dd207119f6e292b0fa45131b721ab7c63616a7e26a7b0defc87
5
5
  SHA512:
6
- metadata.gz: 3c397937ed703003e47e5999a9ffd0cd6004ce0ee02e0b216ba6c99c06dec2b03a37ceb9163b8fba36cc413747782fc13ccd84ed7218e307cd21f92ded5566da
7
- data.tar.gz: ca65db25d697572ceae8f048bb5672407639fd9aeebf0bc3e0f691cfd08e3993983d78c80f80ad9997852725ebd319ca0b19c069c455fde3b8b43c4c54f73e41
6
+ metadata.gz: 140f40e9b708ae00f732e7d1df67558d000094f7000d0e521f3fef6f0f49155148123e96988edfc57840ba96cfa75097327768b26628a04bd359bfc5c71ed2e9
7
+ data.tar.gz: e8537c6c21a1b0143604b7f699849b8cf1065bc88f2fdad0cce65d199e96c604d1440d4304cf0efc129f47c559f35ca220a5721fd2446fa31253d610459344e9
@@ -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)
@@ -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
@@ -1,3 +1,4 @@
1
+ require_relative "context_attribute"
1
2
  require "time"
2
3
 
3
4
  module Featureflip
@@ -10,7 +11,7 @@ module Featureflip
10
11
  REGEX_TIMEOUT_SECONDS = 0.1
11
12
 
12
13
  def evaluate_condition(condition, context)
13
- attr_value = context[condition.attribute]
14
+ attr_value = ContextAttribute.resolve(context, condition.attribute)
14
15
 
15
16
  return condition.negate if attr_value.nil?
16
17
 
@@ -0,0 +1,24 @@
1
+ module Featureflip
2
+ module Evaluation
3
+ # Looks an attribute up in an evaluation context hash.
4
+ #
5
+ # "user_id" is the canonical spelling of the user identifier and "userId" is
6
+ # accepted as an alias (#4518). A lookup of exactly one of the two returns
7
+ # "user_id" when it is non-nil, else "userId" when it is non-nil, else nil.
8
+ # An empty string is a value and does not fall through. Every other key,
9
+ # including other casings, is an ordinary attribute.
10
+ module ContextAttribute
11
+ module_function
12
+
13
+ def resolve(context, attribute)
14
+ if attribute == "user_id" || attribute == "userId"
15
+ value = context["user_id"]
16
+ value = context["userId"] if value.nil?
17
+ value
18
+ else
19
+ context[attribute]
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,3 +1,4 @@
1
+ require_relative "context_attribute"
1
2
  require_relative "condition_evaluator"
2
3
  require_relative "bucketing"
3
4
  require_relative "canonical_number"
@@ -164,7 +165,7 @@ module Featureflip
164
165
  end
165
166
 
166
167
  def warn_unbucketable(flag, rule_id, serve)
167
- UnbucketableWarnings.warn(@logger, flag.key, rule_id, serve.bucket_by || "userId")
168
+ UnbucketableWarnings.warn(@logger, flag.key, rule_id, serve.bucket_by || "user_id")
168
169
  end
169
170
 
170
171
  # Returns the variation key to serve, or nil when a Rollout serve can't bucket
@@ -178,13 +179,13 @@ module Featureflip
178
179
  end
179
180
 
180
181
  def resolve_rollout(serve, context)
181
- bucket_by = serve.bucket_by || "userId"
182
- bucket_value = context[bucket_by]
183
- # Alias "userId" <-> "user_id" for the built-in user identifier
184
- bucket_value = context["user_id"] if bucket_value.nil? && bucket_by == "userId"
185
- bucket_value = context["userId"] if bucket_value.nil? && bucket_by == "user_id"
182
+ bucket_by = serve.bucket_by || "user_id"
183
+ # "user_id" and "userId" resolve the same identity (user_id first).
184
+ bucket_value = ContextAttribute.resolve(context, bucket_by)
186
185
  bucket_value_str =
187
- if bucket_value.nil? then ""
186
+ # An Array or Hash (a JSON array or object, empty or not) has no spelling every
187
+ # SDK shares, so it can't be bucketed, like a missing value (#4572).
188
+ if bucket_value.nil? || bucket_value.is_a?(Array) || bucket_value.is_a?(Hash) then ""
188
189
  elsif bucket_value.is_a?(Float) then CanonicalNumber.format(bucket_value)
189
190
  elsif defined?(::BigDecimal) && bucket_value.is_a?(::BigDecimal) then CanonicalNumber.format_big_decimal(bucket_value)
190
191
  else bucket_value.to_s
@@ -196,7 +197,7 @@ module Featureflip
196
197
  # empty key. Mirrors the engine + C#/Java SDK evaluators.
197
198
  return serve.variation || "" if (serve.variations || []).empty?
198
199
 
199
- # A missing, nil or empty bucket value can't be bucketed: return nil so the caller
200
+ # A missing, nil, empty or non-scalar bucket value can't be bucketed: return nil so the caller
200
201
  # skips the rule (or serves the fallthrough's variation) instead of hashing the empty
201
202
  # string into an arbitrary bucket. Whitespace, "0", 0 and false bucket normally (#4236).
202
203
  return nil if bucket_value_str == ""
@@ -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
- def initialize(sdk_key:, config:)
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 for SDK key #{@sdk_key}")
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
- Timeout.timeout(@config.init_timeout) do
354
- flags, segments = @http_client.get_flags
355
- @store.init(flags, segments)
356
- @initialized = true
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
- rescue Timeout::Error
359
- raise InitializationError, "Initialization timed out after #{@config.init_timeout}s"
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
- notify_update_listeners(@store.init(flags, segments))
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
- notify_update_listeners(@store.upsert(flag))
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
- notify_update_listeners(@store.remove_flag(key))
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
@@ -36,6 +36,10 @@ module Featureflip
36
36
  @mutex.synchronize { @flags.values }
37
37
  end
38
38
 
39
+ def all_segments
40
+ @mutex.synchronize { @segments.values }
41
+ end
42
+
39
43
  def all_flags_map
40
44
  @mutex.synchronize { @flags.dup }
41
45
  end
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.13.0"
2
+ VERSION = "2.14.0"
3
3
  end
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.13.0
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-10-27 00:00:00.000000000 Z
11
+ date: 2026-11-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -81,6 +81,7 @@ files:
81
81
  - lib/featureflip/evaluation/bucketing.rb
82
82
  - lib/featureflip/evaluation/canonical_number.rb
83
83
  - lib/featureflip/evaluation/condition_evaluator.rb
84
+ - lib/featureflip/evaluation/context_attribute.rb
84
85
  - lib/featureflip/evaluation/evaluator.rb
85
86
  - lib/featureflip/evaluation/unbucketable_warnings.rb
86
87
  - lib/featureflip/events/event.rb
@@ -92,6 +93,7 @@ files:
92
93
  - lib/featureflip/models/segment.rb
93
94
  - lib/featureflip/sdk_identity.rb
94
95
  - lib/featureflip/shared_core.rb
96
+ - lib/featureflip/snapshot.rb
95
97
  - lib/featureflip/store/flag_store.rb
96
98
  - lib/featureflip/store/update_diff.rb
97
99
  - lib/featureflip/version.rb