featureflip 1.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 681f3a17deed31dc5488ce422a58c1bdf6efe2d8af6edcf2fb7551b97552d3db
4
- data.tar.gz: 9ea0d5f7091be0e8152a6a58a651053c2e5112486d1398d4c146001aae91c5bc
3
+ metadata.gz: 74310b5e1a7545b5d289a4f09d32c7e396f3f03bcb5af9265fe970667f2aedfd
4
+ data.tar.gz: 6f0fd700429f63dbba04b3311c88700e9281113f0b114a6c635797745ca1cddc
5
5
  SHA512:
6
- metadata.gz: 0ec0ea000a63091c062a807dc5c5c212bc6735ea726278e50f21a9705e2d1860550736e1a8c506732ee4f961922dcba6069721951811de3696180ab703dee601
7
- data.tar.gz: d26be8ff065c4efd45566342e8d7f1f39f461e0c0393202ac5e61edb85960f404b957900f2b925ba0eb9ab7ed41f7764486704ef39e5efad66ccc40c96a75510
6
+ metadata.gz: 6eb646f7ba550b44639e2d294ad8b171d2a9931f5004afcdb7621db46abc18dfa047732da544e74a9167a509da1fd5018e71e49f7126e0753ee87f8ac9e2bccf
7
+ data.tar.gz: ca59f44a28682ac0fcc5924d4a19e3bb9df8ae76809dfa133f34a57d4bf24243e3cf4b2b7a7f82a54efc0249653a6d54a1fd6ca4c5d0b685fbe2438282e27776
@@ -1,247 +1,86 @@
1
- require "timeout"
2
-
3
1
  module Featureflip
4
2
  class Client
5
- attr_reader :initialized
6
- alias_method :initialized?, :initialized
3
+ private_class_method :new
7
4
 
8
- def initialize(sdk_key: nil, config: nil)
9
- @sdk_key = sdk_key || ENV["FEATUREFLIP_SDK_KEY"]
10
- raise ConfigurationError, "SDK key is required. Pass sdk_key parameter or set FEATUREFLIP_SDK_KEY env var." unless @sdk_key
5
+ def self.get(sdk_key = nil, config: nil)
6
+ sdk_key ||= ENV["FEATUREFLIP_SDK_KEY"]
7
+ raise ConfigurationError, "SDK key is required. Pass sdk_key parameter or set FEATUREFLIP_SDK_KEY env var." unless sdk_key
11
8
 
12
- @config = config || Config.new
13
- @store = Store::FlagStore.new
14
- @evaluator = Evaluation::Evaluator.new
15
- @initialized = false
16
- @closed = false
17
- @test_mode = false
18
- @test_values = {}
19
- @http_client = nil
20
- @streaming_handler = nil
21
- @polling_handler = nil
22
- @event_processor = nil
23
-
24
- initialize!
9
+ config ||= Config.new
10
+ core = SharedCore._get_or_create(sdk_key, config)
11
+ new(core)
12
+ end
13
+
14
+ def self.for_testing(flags)
15
+ core = SharedCore._create_for_testing(flags)
16
+ new(core)
17
+ end
18
+
19
+ def initialized?
20
+ @core.initialized?
25
21
  end
26
22
 
27
23
  def bool_variation(key, context, default_value)
28
- evaluate_flag(key, context, default_value)
24
+ return default_value if @closed
25
+ @core.bool_variation(key, context, default_value)
29
26
  end
30
27
 
31
28
  def string_variation(key, context, default_value)
32
- evaluate_flag(key, context, default_value)
29
+ return default_value if @closed
30
+ @core.string_variation(key, context, default_value)
33
31
  end
34
32
 
35
33
  def number_variation(key, context, default_value)
36
- evaluate_flag(key, context, default_value)
34
+ return default_value if @closed
35
+ @core.number_variation(key, context, default_value)
37
36
  end
38
37
 
39
38
  def json_variation(key, context, default_value)
40
- evaluate_flag(key, context, default_value)
39
+ return default_value if @closed
40
+ @core.json_variation(key, context, default_value)
41
41
  end
42
42
 
43
43
  def variation_detail(key, context, default_value)
44
- context = normalize_context(context)
45
-
46
- if @test_mode
47
- value = @test_values.fetch(key, default_value)
48
- reason = @test_values.key?(key) ? "Fallthrough" : "FlagNotFound"
49
- return Models::EvaluationDetail.new(value: value, reason: reason)
50
- end
51
-
52
- flag = @store.get_flag(key)
53
- unless flag
54
- record_evaluation(key, context, nil)
55
- return Models::EvaluationDetail.new(value: default_value, reason: "FlagNotFound")
44
+ if @closed
45
+ return Models::EvaluationDetail.new(value: default_value, reason: "Error")
56
46
  end
57
-
58
- result = @evaluator.evaluate(flag, context, get_segment: method(:get_segment))
59
- value = result.value.nil? ? default_value : result.value
60
- record_evaluation(key, context, result.variation_key)
61
-
62
- Models::EvaluationDetail.new(
63
- value: value,
64
- reason: result.reason,
65
- rule_id: result.rule_id,
66
- variation_key: result.variation_key
67
- )
68
- rescue StandardError
69
- Models::EvaluationDetail.new(value: default_value, reason: "Error")
47
+ @core.variation_detail(key, context, default_value)
70
48
  end
71
49
 
72
50
  def track(event_key, context, metadata = nil)
73
- return unless @event_processor
74
-
75
- context = normalize_context(context)
76
- @event_processor.queue_event({
77
- type: "Custom",
78
- flagKey: event_key,
79
- userId: context["user_id"]&.to_s,
80
- metadata: metadata || {},
81
- timestamp: Time.now.utc.iso8601
82
- })
51
+ return if @closed
52
+ @core.track(event_key, context, metadata)
83
53
  end
84
54
 
85
55
  def identify(context)
86
- return unless @event_processor
87
-
88
- context = normalize_context(context)
89
- @event_processor.queue_event({
90
- type: "Identify",
91
- flagKey: "$identify",
92
- userId: context["user_id"]&.to_s,
93
- timestamp: Time.now.utc.iso8601
94
- })
56
+ return if @closed
57
+ @core.identify(context)
95
58
  end
96
59
 
97
60
  def flush
98
- @event_processor&.flush
61
+ return if @closed
62
+ @core.flush
99
63
  end
100
64
 
101
65
  def close
102
- @closed = true
103
- @streaming_handler&.stop
104
- @streaming_handler = nil
105
- @polling_handler&.stop
106
- @polling_handler = nil
107
- @event_processor&.stop
108
- @event_processor = nil
66
+ @close_mutex.synchronize do
67
+ return if @closed
68
+ @closed = true
69
+ end
70
+ @core._release
109
71
  end
110
72
 
111
73
  def restart
112
74
  return if @closed
113
-
114
- @streaming_handler&.stop
115
- @polling_handler&.stop
116
- @event_processor&.stop
117
-
118
- if @config.streaming
119
- start_streaming
120
- else
121
- start_polling
122
- end
123
- start_event_processor if @config.send_events
124
- end
125
-
126
- def self.for_testing(flags)
127
- instance = allocate
128
- instance.instance_variable_set(:@sdk_key, "test-key")
129
- instance.instance_variable_set(:@config, Config.new)
130
- instance.instance_variable_set(:@store, Store::FlagStore.new)
131
- instance.instance_variable_set(:@evaluator, Evaluation::Evaluator.new)
132
- instance.instance_variable_set(:@initialized, true)
133
- instance.instance_variable_set(:@closed, false)
134
- instance.instance_variable_set(:@test_mode, true)
135
- instance.instance_variable_set(:@test_values, flags.dup)
136
- instance.instance_variable_set(:@http_client, nil)
137
- instance.instance_variable_set(:@streaming_handler, nil)
138
- instance.instance_variable_set(:@polling_handler, nil)
139
- instance.instance_variable_set(:@event_processor, nil)
140
- instance
75
+ @core.restart
141
76
  end
142
77
 
143
78
  private
144
79
 
145
- def initialize!
146
- @http_client = Http::Client.new(@sdk_key, @config)
147
- fetch_initial_flags
148
- start_data_source
149
- start_event_processor if @config.send_events
150
- end
151
-
152
- def fetch_initial_flags
153
- Timeout.timeout(@config.init_timeout) do
154
- flags, segments = @http_client.get_flags
155
- @store.init(flags, segments)
156
- @initialized = true
157
- end
158
- rescue Timeout::Error
159
- raise InitializationError, "Initialization timed out after #{@config.init_timeout}s"
160
- rescue InitializationError
161
- raise
162
- rescue StandardError => e
163
- raise InitializationError, "Failed to initialize: #{e.message}"
164
- end
165
-
166
- def start_data_source
167
- return if @closed
168
-
169
- if @config.streaming
170
- start_streaming
171
- else
172
- start_polling
173
- end
174
- end
175
-
176
- def start_streaming
177
- @streaming_handler = DataSource::StreamingHandler.new(
178
- sdk_key: @sdk_key,
179
- config: @config,
180
- http_client: @http_client,
181
- on_flag_updated: ->(flag) { @store.upsert(flag) },
182
- on_flag_deleted: ->(key) { @store.remove_flag(key) },
183
- on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
184
- on_error: ->(_err) { },
185
- on_give_up: -> { fallback_to_polling }
186
- )
187
- @streaming_handler.start
188
- end
189
-
190
- def fallback_to_polling
191
- @config.logger&.warn("Featureflip: streaming retries exhausted, falling back to polling")
192
- @streaming_handler = nil
193
- start_polling
194
- end
195
-
196
- def start_polling
197
- @polling_handler = DataSource::PollingHandler.new(
198
- http_client: @http_client,
199
- config: @config,
200
- on_update: ->(flags, segments) { @store.init(flags, segments) },
201
- on_error: ->(_err) { }
202
- )
203
- @polling_handler.start
204
- end
205
-
206
- def start_event_processor
207
- @event_processor = Events::EventProcessor.new(
208
- @http_client,
209
- flush_interval: @config.flush_interval,
210
- flush_batch_size: @config.flush_batch_size
211
- )
212
- @event_processor.start
213
- end
214
-
215
- def evaluate_flag(key, context, default_value)
216
- if @test_mode
217
- return @test_values.fetch(key, default_value)
218
- end
219
-
220
- detail = variation_detail(key, context, default_value)
221
- detail.value
222
- rescue StandardError
223
- default_value
224
- end
225
-
226
- def get_segment(key)
227
- @store.get_segment(key)
228
- end
229
-
230
- def normalize_context(context)
231
- return {} if context.nil?
232
- context.transform_keys(&:to_s)
233
- end
234
-
235
- def record_evaluation(key, context, variation_key)
236
- return unless @event_processor
237
-
238
- @event_processor.queue_event({
239
- type: "Evaluation",
240
- flagKey: key,
241
- userId: context["user_id"]&.to_s,
242
- variation: variation_key,
243
- timestamp: Time.now.utc.iso8601
244
- })
80
+ def initialize(core)
81
+ @core = core
82
+ @closed = false
83
+ @close_mutex = Mutex.new
245
84
  end
246
85
  end
247
86
  end
@@ -4,20 +4,37 @@ require_relative "bucketing"
4
4
  module Featureflip
5
5
  module Evaluation
6
6
  class Evaluator
7
+ # Mirrors packages/js-sdk/src/core/evaluator.ts. The guard uses `>` (not `>=`)
8
+ # so a chain of MAX_PREREQUISITE_DEPTH + 1 nested flags trips the cap — matches
9
+ # the JS reference implementation; see prerequisite_spec.rb depth test.
10
+ MAX_PREREQUISITE_DEPTH = 10
11
+
7
12
  def initialize
8
13
  @condition_evaluator = ConditionEvaluator.new
9
14
  end
10
15
 
11
- def evaluate(flag, context, get_segment: nil)
16
+ def evaluate(flag, context, get_segment: nil, all_flags: {})
17
+ evaluate_with_shared_memo(flag, context, get_segment: get_segment, all_flags: all_flags, memo: {})
18
+ end
19
+
20
+ def evaluate_with_shared_memo(flag, context, all_flags:, memo:, get_segment: nil)
21
+ evaluate_internal(flag, context, get_segment, all_flags, 0, memo)
22
+ end
23
+
24
+ private
25
+
26
+ def evaluate_internal(flag, context, get_segment, all_flags, depth, memo)
27
+ if depth > MAX_PREREQUISITE_DEPTH
28
+ return off_result(flag, reason: "Error")
29
+ end
30
+
12
31
  unless flag.enabled
13
- variation = flag.get_variation(flag.off_variation)
14
- return Models::EvaluationDetail.new(
15
- value: variation&.value,
16
- reason: "FlagDisabled",
17
- variation_key: flag.off_variation
18
- )
32
+ return off_result(flag, reason: "FlagDisabled")
19
33
  end
20
34
 
35
+ prereq_failure = resolve_prerequisites(flag, context, get_segment, all_flags, depth, memo)
36
+ return prereq_failure if prereq_failure
37
+
21
38
  sorted_rules = flag.rules.sort_by(&:priority)
22
39
  sorted_rules.each do |rule|
23
40
  conditions_match = if rule.segment_key && get_segment
@@ -38,25 +55,72 @@ module Featureflip
38
55
  if conditions_match
39
56
  variation_key = resolve_serve(rule.serve, context)
40
57
  variation = flag.get_variation(variation_key)
41
- return Models::EvaluationDetail.new(
58
+ result = Models::EvaluationDetail.new(
42
59
  value: variation&.value,
43
60
  reason: "RuleMatch",
44
61
  rule_id: rule.id,
45
62
  variation_key: variation_key
46
63
  )
64
+ memo[flag.key] = result
65
+ return result
47
66
  end
48
67
  end
49
68
 
50
69
  variation_key = resolve_serve(flag.fallthrough, context)
51
70
  variation = flag.get_variation(variation_key)
52
- Models::EvaluationDetail.new(
71
+ result = Models::EvaluationDetail.new(
53
72
  value: variation&.value,
54
73
  reason: "Fallthrough",
55
74
  variation_key: variation_key
56
75
  )
76
+ memo[flag.key] = result
77
+ result
57
78
  end
58
79
 
59
- private
80
+ # Returns nil when all prerequisites pass; otherwise returns the off-variation
81
+ # result for the flag and memoises it under flag.key. Mirrors the per-branch
82
+ # memo writes in the JS SDK evaluator.
83
+ def resolve_prerequisites(flag, context, get_segment, all_flags, depth, memo)
84
+ prerequisites = flag.prerequisites || []
85
+ prerequisites.each do |prereq|
86
+ key = prereq.prerequisite_flag_key
87
+ prereq_result = memo[key]
88
+
89
+ unless prereq_result
90
+ prereq_flag = all_flags[key]
91
+ unless prereq_flag
92
+ return memoise(memo, flag.key, off_result(flag, reason: "PrerequisiteFailed", prerequisite_key: key))
93
+ end
94
+
95
+ prereq_result = evaluate_internal(prereq_flag, context, get_segment, all_flags, depth + 1, memo)
96
+ memo[key] = prereq_result
97
+ end
98
+
99
+ if prereq_result.reason == "Error"
100
+ return memoise(memo, flag.key, off_result(flag, reason: "Error"))
101
+ end
102
+
103
+ if prereq_result.variation_key != prereq.expected_variation_key
104
+ return memoise(memo, flag.key, off_result(flag, reason: "PrerequisiteFailed", prerequisite_key: key))
105
+ end
106
+ end
107
+ nil
108
+ end
109
+
110
+ def memoise(memo, key, result)
111
+ memo[key] = result
112
+ result
113
+ end
114
+
115
+ def off_result(flag, reason:, prerequisite_key: nil)
116
+ variation = flag.get_variation(flag.off_variation)
117
+ Models::EvaluationDetail.new(
118
+ value: variation&.value,
119
+ reason: reason,
120
+ variation_key: flag.off_variation,
121
+ prerequisite_key: prerequisite_key
122
+ )
123
+ end
60
124
 
61
125
  def resolve_serve(serve, context)
62
126
  if serve.type == "Fixed"
@@ -82,7 +82,15 @@ module Featureflip
82
82
  variations: (data["variations"] || []).map { |v| Models::Variation.new(key: v["key"], value: v["value"]) },
83
83
  rules: (data["rules"] || []).map { |r| parse_rule(r) },
84
84
  fallthrough: parse_serve(data["fallthrough"]),
85
- off_variation: data["offVariation"]
85
+ off_variation: data["offVariation"],
86
+ prerequisites: (data["prerequisites"] || []).map { |p| parse_prerequisite(p) }
87
+ )
88
+ end
89
+
90
+ def parse_prerequisite(data)
91
+ Models::Prerequisite.new(
92
+ prerequisite_flag_key: data["prerequisiteFlagKey"],
93
+ expected_variation_key: data["expectedVariationKey"]
86
94
  )
87
95
  end
88
96
 
@@ -1,7 +1,7 @@
1
1
  module Featureflip
2
2
  module Models
3
- EvaluationDetail = Struct.new(:value, :reason, :rule_id, :variation_key, keyword_init: true) do
4
- def initialize(value:, reason:, rule_id: nil, variation_key: nil)
3
+ EvaluationDetail = Struct.new(:value, :reason, :rule_id, :variation_key, :prerequisite_key, keyword_init: true) do
4
+ def initialize(value:, reason:, rule_id: nil, variation_key: nil, prerequisite_key: nil)
5
5
  super
6
6
  end
7
7
  end
@@ -28,7 +28,16 @@ module Featureflip
28
28
  end
29
29
  end
30
30
 
31
- FlagConfiguration = Struct.new(:key, :version, :type, :enabled, :variations, :rules, :fallthrough, :off_variation, keyword_init: true) do
31
+ Prerequisite = Struct.new(:prerequisite_flag_key, :expected_variation_key, keyword_init: true)
32
+
33
+ FlagConfiguration = Struct.new(
34
+ :key, :version, :type, :enabled, :variations, :rules, :fallthrough, :off_variation, :prerequisites,
35
+ keyword_init: true
36
+ ) do
37
+ def initialize(key:, version:, type:, enabled:, variations:, rules:, fallthrough:, off_variation:, prerequisites: [])
38
+ super
39
+ end
40
+
32
41
  def get_variation(key)
33
42
  @variations_by_key ||= variations.each_with_object({}) { |v, h| h[v.key] = v }
34
43
  @variations_by_key[key]
@@ -0,0 +1,376 @@
1
+ require "timeout"
2
+
3
+ module Featureflip
4
+ class SharedCore
5
+ LIVE_CORES = {}
6
+ LIVE_CORES_MUTEX = Mutex.new
7
+
8
+ # --- Class-level factory methods ---
9
+
10
+ def self._get_or_create(sdk_key, config)
11
+ LIVE_CORES_MUTEX.synchronize do
12
+ existing = LIVE_CORES[sdk_key]
13
+
14
+ if existing
15
+ if existing._acquire
16
+ unless _configs_equal(existing._config, config)
17
+ config.logger&.warn(
18
+ "Featureflip: Client.get called with different config for same SDK key. " \
19
+ "Using existing configuration. Close all handles first to apply new config."
20
+ )
21
+ end
22
+ return existing
23
+ else
24
+ # Stale entry — remove and replace
25
+ LIVE_CORES.delete(sdk_key)
26
+ end
27
+ end
28
+
29
+ core = new(sdk_key: sdk_key, config: config)
30
+ LIVE_CORES[sdk_key] = core
31
+ core
32
+ end
33
+ end
34
+
35
+ def self._create_for_testing(flags)
36
+ core = allocate
37
+ core.send(:init_test_mode, flags)
38
+ core
39
+ end
40
+
41
+ def self._reset_for_testing
42
+ cores_to_release = LIVE_CORES_MUTEX.synchronize do
43
+ snapshot = LIVE_CORES.values.dup
44
+ LIVE_CORES.clear
45
+ snapshot
46
+ end
47
+ cores_to_release.each { |c| c._release }
48
+ end
49
+
50
+ # --- Instance methods ---
51
+
52
+ def initialize(sdk_key:, config:)
53
+ @sdk_key = sdk_key
54
+ @config = config
55
+ @store = Store::FlagStore.new
56
+ @evaluator = Evaluation::Evaluator.new
57
+ @initialized = false
58
+ @closed = false
59
+ @test_mode = false
60
+ @test_values = {}
61
+ @http_client = nil
62
+ @streaming_handler = nil
63
+ @polling_handler = nil
64
+ @event_processor = nil
65
+ @ref_count = 1
66
+ @ref_mutex = Mutex.new
67
+ @shut_down = false
68
+
69
+ bootstrap!
70
+ end
71
+
72
+ def _acquire
73
+ @ref_mutex.synchronize do
74
+ return false if @ref_count <= 0
75
+ @ref_count += 1
76
+ true
77
+ end
78
+ end
79
+
80
+ def _release
81
+ run_shutdown = false
82
+ @ref_mutex.synchronize do
83
+ return if @ref_count <= 0
84
+ @ref_count -= 1
85
+ if @ref_count == 0 && !@shut_down
86
+ @shut_down = true
87
+ run_shutdown = true
88
+ end
89
+ end
90
+ _shutdown if run_shutdown
91
+ end
92
+
93
+ def _config
94
+ @config
95
+ end
96
+
97
+ def _ref_count
98
+ @ref_mutex.synchronize { @ref_count }
99
+ end
100
+
101
+ def initialized?
102
+ @initialized
103
+ end
104
+
105
+ # --- Evaluation methods ---
106
+
107
+ def bool_variation(key, context, default_value)
108
+ evaluate_flag(key, context, default_value)
109
+ end
110
+
111
+ def string_variation(key, context, default_value)
112
+ evaluate_flag(key, context, default_value)
113
+ end
114
+
115
+ def number_variation(key, context, default_value)
116
+ evaluate_flag(key, context, default_value)
117
+ end
118
+
119
+ def json_variation(key, context, default_value)
120
+ evaluate_flag(key, context, default_value)
121
+ end
122
+
123
+ def variation_detail(key, context, default_value)
124
+ context = normalize_context(context)
125
+
126
+ if @test_mode
127
+ value = @test_values.fetch(key, default_value)
128
+ reason = @test_values.key?(key) ? "Fallthrough" : "FlagNotFound"
129
+ return Models::EvaluationDetail.new(value: value, reason: reason)
130
+ end
131
+
132
+ flag = @store.get_flag(key)
133
+ unless flag
134
+ record_evaluation(key, context, nil)
135
+ return Models::EvaluationDetail.new(value: default_value, reason: "FlagNotFound")
136
+ end
137
+
138
+ result = @evaluator.evaluate(
139
+ flag,
140
+ context,
141
+ get_segment: method(:get_segment),
142
+ all_flags: @store.all_flags_map
143
+ )
144
+ value = result.value.nil? ? default_value : result.value
145
+ record_evaluation(key, context, result.variation_key)
146
+
147
+ Models::EvaluationDetail.new(
148
+ value: value,
149
+ reason: result.reason,
150
+ rule_id: result.rule_id,
151
+ variation_key: result.variation_key,
152
+ prerequisite_key: result.prerequisite_key
153
+ )
154
+ rescue StandardError
155
+ # Prerequisite-resolution failures return PrerequisiteFailed cleanly through
156
+ # the evaluator; this rescue only fires on unexpected exceptions (malformed
157
+ # config, programming errors), so prerequisite_key has no defined value.
158
+ Models::EvaluationDetail.new(value: default_value, reason: "Error", prerequisite_key: nil)
159
+ end
160
+
161
+ # --- Event methods ---
162
+
163
+ def track(event_key, context, metadata = nil)
164
+ return unless @event_processor
165
+
166
+ context = normalize_context(context)
167
+ @event_processor.queue_event({
168
+ type: "Custom",
169
+ flagKey: event_key,
170
+ userId: context["user_id"]&.to_s,
171
+ metadata: metadata || {},
172
+ timestamp: Time.now.utc.iso8601
173
+ })
174
+ end
175
+
176
+ def identify(context)
177
+ return unless @event_processor
178
+
179
+ context = normalize_context(context)
180
+ @event_processor.queue_event({
181
+ type: "Identify",
182
+ flagKey: "$identify",
183
+ userId: context["user_id"]&.to_s,
184
+ timestamp: Time.now.utc.iso8601
185
+ })
186
+ end
187
+
188
+ def flush
189
+ @event_processor&.flush
190
+ end
191
+
192
+ def restart
193
+ return if @shut_down
194
+
195
+ @streaming_handler&.stop
196
+ @polling_handler&.stop
197
+ @event_processor&.stop
198
+
199
+ if @config.streaming
200
+ start_streaming
201
+ else
202
+ start_polling
203
+ end
204
+ start_event_processor if @config.send_events
205
+ end
206
+
207
+ private
208
+
209
+ def _shutdown
210
+ LIVE_CORES_MUTEX.synchronize do
211
+ LIVE_CORES.delete(@sdk_key) if LIVE_CORES[@sdk_key].equal?(self)
212
+ end
213
+ _shutdown_internal
214
+ end
215
+
216
+ def _shutdown_internal
217
+ @closed = true
218
+ begin
219
+ @streaming_handler&.stop
220
+ rescue StandardError
221
+ # ignore
222
+ end
223
+ @streaming_handler = nil
224
+
225
+ begin
226
+ @polling_handler&.stop
227
+ rescue StandardError
228
+ # ignore
229
+ end
230
+ @polling_handler = nil
231
+
232
+ begin
233
+ @event_processor&.stop
234
+ rescue StandardError
235
+ # ignore
236
+ end
237
+ @event_processor = nil
238
+
239
+ @config.logger&.info("Featureflip: core shut down for SDK key #{@sdk_key}")
240
+ end
241
+
242
+ def bootstrap!
243
+ @http_client = Http::Client.new(@sdk_key, @config)
244
+ fetch_initial_flags
245
+ start_data_source
246
+ start_event_processor if @config.send_events
247
+ end
248
+
249
+ def fetch_initial_flags
250
+ Timeout.timeout(@config.init_timeout) do
251
+ flags, segments = @http_client.get_flags
252
+ @store.init(flags, segments)
253
+ @initialized = true
254
+ end
255
+ rescue Timeout::Error
256
+ raise InitializationError, "Initialization timed out after #{@config.init_timeout}s"
257
+ rescue InitializationError
258
+ raise
259
+ rescue StandardError => e
260
+ raise InitializationError, "Failed to initialize: #{e.message}"
261
+ end
262
+
263
+ def start_data_source
264
+ return if @closed
265
+
266
+ if @config.streaming
267
+ start_streaming
268
+ else
269
+ start_polling
270
+ end
271
+ end
272
+
273
+ def start_streaming
274
+ @streaming_handler = DataSource::StreamingHandler.new(
275
+ sdk_key: @sdk_key,
276
+ config: @config,
277
+ http_client: @http_client,
278
+ on_flag_updated: ->(flag) { @store.upsert(flag) },
279
+ on_flag_deleted: ->(key) { @store.remove_flag(key) },
280
+ on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
281
+ on_error: ->(_err) { },
282
+ on_give_up: -> { fallback_to_polling }
283
+ )
284
+ @streaming_handler.start
285
+ end
286
+
287
+ def fallback_to_polling
288
+ @config.logger&.warn("Featureflip: streaming retries exhausted, falling back to polling")
289
+ @streaming_handler = nil
290
+ start_polling
291
+ end
292
+
293
+ def start_polling
294
+ @polling_handler = DataSource::PollingHandler.new(
295
+ http_client: @http_client,
296
+ config: @config,
297
+ on_update: ->(flags, segments) { @store.init(flags, segments) },
298
+ on_error: ->(_err) { }
299
+ )
300
+ @polling_handler.start
301
+ end
302
+
303
+ def start_event_processor
304
+ @event_processor = Events::EventProcessor.new(
305
+ @http_client,
306
+ flush_interval: @config.flush_interval,
307
+ flush_batch_size: @config.flush_batch_size
308
+ )
309
+ @event_processor.start
310
+ end
311
+
312
+ def evaluate_flag(key, context, default_value)
313
+ if @test_mode
314
+ return @test_values.fetch(key, default_value)
315
+ end
316
+
317
+ detail = variation_detail(key, context, default_value)
318
+ detail.value
319
+ rescue StandardError
320
+ default_value
321
+ end
322
+
323
+ def get_segment(key)
324
+ @store.get_segment(key)
325
+ end
326
+
327
+ def normalize_context(context)
328
+ return {} if context.nil?
329
+ context.transform_keys(&:to_s)
330
+ end
331
+
332
+ def record_evaluation(key, context, variation_key)
333
+ return unless @event_processor
334
+
335
+ @event_processor.queue_event({
336
+ type: "Evaluation",
337
+ flagKey: key,
338
+ userId: context["user_id"]&.to_s,
339
+ variation: variation_key,
340
+ timestamp: Time.now.utc.iso8601
341
+ })
342
+ end
343
+
344
+ def init_test_mode(flags)
345
+ @sdk_key = "test-key"
346
+ @config = Config.new
347
+ @store = Store::FlagStore.new
348
+ @evaluator = Evaluation::Evaluator.new
349
+ @initialized = true
350
+ @closed = false
351
+ @test_mode = true
352
+ @test_values = flags.dup
353
+ @http_client = nil
354
+ @streaming_handler = nil
355
+ @polling_handler = nil
356
+ @event_processor = nil
357
+ @ref_count = 1
358
+ @ref_mutex = Mutex.new
359
+ @shut_down = false
360
+ end
361
+
362
+ def self._configs_equal(a, b)
363
+ a.base_url == b.base_url &&
364
+ a.streaming == b.streaming &&
365
+ a.poll_interval == b.poll_interval &&
366
+ a.flush_interval == b.flush_interval &&
367
+ a.flush_batch_size == b.flush_batch_size &&
368
+ a.init_timeout == b.init_timeout &&
369
+ a.connect_timeout == b.connect_timeout &&
370
+ a.read_timeout == b.read_timeout &&
371
+ a.send_events == b.send_events
372
+ end
373
+
374
+ private_class_method :_configs_equal
375
+ end
376
+ end
@@ -28,6 +28,10 @@ module Featureflip
28
28
  @mutex.synchronize { @flags.values }
29
29
  end
30
30
 
31
+ def all_flags_map
32
+ @mutex.synchronize { @flags.dup }
33
+ end
34
+
31
35
  def upsert(flag)
32
36
  @mutex.synchronize do
33
37
  existing = @flags[flag.key]
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "1.0.1"
2
+ VERSION = "2.1.0"
3
3
  end
data/lib/featureflip.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "featureflip/events/event"
13
13
  require_relative "featureflip/events/event_processor"
14
14
  require_relative "featureflip/data_source/streaming"
15
15
  require_relative "featureflip/data_source/polling"
16
+ require_relative "featureflip/shared_core"
16
17
  require_relative "featureflip/client"
17
18
 
18
19
  module Featureflip
@@ -26,7 +27,7 @@ module Featureflip
26
27
  @config = Config.new
27
28
  yield @config if block_given?
28
29
  @config.validate!
29
- @default_client = Client.new(sdk_key: @config.sdk_key, config: @config)
30
+ @default_client = Client.get(@config.sdk_key, config: @config)
30
31
  end
31
32
  end
32
33
 
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: 1.0.1
4
+ version: 2.1.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-04-03 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -73,6 +73,7 @@ files:
73
73
  - lib/featureflip/models/evaluation_detail.rb
74
74
  - lib/featureflip/models/flag.rb
75
75
  - lib/featureflip/models/segment.rb
76
+ - lib/featureflip/shared_core.rb
76
77
  - lib/featureflip/store/flag_store.rb
77
78
  - lib/featureflip/version.rb
78
79
  homepage: https://featureflip.io