featureflip 1.0.1 → 2.0.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: 3b4009e153b6dc621cee9ed4d8f1aaa7f199396e5d8a8266d949b3367b0f06b8
4
+ data.tar.gz: 1f45c271ce3d180c362cfa095d6027f0d5d48f51cc259fd31215baa8959732d6
5
5
  SHA512:
6
- metadata.gz: 0ec0ea000a63091c062a807dc5c5c212bc6735ea726278e50f21a9705e2d1860550736e1a8c506732ee4f961922dcba6069721951811de3696180ab703dee601
7
- data.tar.gz: d26be8ff065c4efd45566342e8d7f1f39f461e0c0393202ac5e61edb85960f404b957900f2b925ba0eb9ab7ed41f7764486704ef39e5efad66ccc40c96a75510
6
+ metadata.gz: debfee24678c85c688e94935396ef142f3b1f31aaec0b7788c6fc09e51e03e805104f273e53bda66686d4eded49baff6e33f58bb7e0b2543cbdf5cfac1cf9289
7
+ data.tar.gz: ff2cc0874823a1dcd23c3fc29dd70deffb46b0a08fc6c9efce620299bb3200b6edbf78d5352579e2b476725e5870d9bcde1f2a48faafc8e5ccca11e113e750ba
@@ -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
@@ -0,0 +1,367 @@
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(flag, context, get_segment: method(:get_segment))
139
+ value = result.value.nil? ? default_value : result.value
140
+ record_evaluation(key, context, result.variation_key)
141
+
142
+ Models::EvaluationDetail.new(
143
+ value: value,
144
+ reason: result.reason,
145
+ rule_id: result.rule_id,
146
+ variation_key: result.variation_key
147
+ )
148
+ rescue StandardError
149
+ Models::EvaluationDetail.new(value: default_value, reason: "Error")
150
+ end
151
+
152
+ # --- Event methods ---
153
+
154
+ def track(event_key, context, metadata = nil)
155
+ return unless @event_processor
156
+
157
+ context = normalize_context(context)
158
+ @event_processor.queue_event({
159
+ type: "Custom",
160
+ flagKey: event_key,
161
+ userId: context["user_id"]&.to_s,
162
+ metadata: metadata || {},
163
+ timestamp: Time.now.utc.iso8601
164
+ })
165
+ end
166
+
167
+ def identify(context)
168
+ return unless @event_processor
169
+
170
+ context = normalize_context(context)
171
+ @event_processor.queue_event({
172
+ type: "Identify",
173
+ flagKey: "$identify",
174
+ userId: context["user_id"]&.to_s,
175
+ timestamp: Time.now.utc.iso8601
176
+ })
177
+ end
178
+
179
+ def flush
180
+ @event_processor&.flush
181
+ end
182
+
183
+ def restart
184
+ return if @shut_down
185
+
186
+ @streaming_handler&.stop
187
+ @polling_handler&.stop
188
+ @event_processor&.stop
189
+
190
+ if @config.streaming
191
+ start_streaming
192
+ else
193
+ start_polling
194
+ end
195
+ start_event_processor if @config.send_events
196
+ end
197
+
198
+ private
199
+
200
+ def _shutdown
201
+ LIVE_CORES_MUTEX.synchronize do
202
+ LIVE_CORES.delete(@sdk_key) if LIVE_CORES[@sdk_key].equal?(self)
203
+ end
204
+ _shutdown_internal
205
+ end
206
+
207
+ def _shutdown_internal
208
+ @closed = true
209
+ begin
210
+ @streaming_handler&.stop
211
+ rescue StandardError
212
+ # ignore
213
+ end
214
+ @streaming_handler = nil
215
+
216
+ begin
217
+ @polling_handler&.stop
218
+ rescue StandardError
219
+ # ignore
220
+ end
221
+ @polling_handler = nil
222
+
223
+ begin
224
+ @event_processor&.stop
225
+ rescue StandardError
226
+ # ignore
227
+ end
228
+ @event_processor = nil
229
+
230
+ @config.logger&.info("Featureflip: core shut down for SDK key #{@sdk_key}")
231
+ end
232
+
233
+ def bootstrap!
234
+ @http_client = Http::Client.new(@sdk_key, @config)
235
+ fetch_initial_flags
236
+ start_data_source
237
+ start_event_processor if @config.send_events
238
+ end
239
+
240
+ def fetch_initial_flags
241
+ Timeout.timeout(@config.init_timeout) do
242
+ flags, segments = @http_client.get_flags
243
+ @store.init(flags, segments)
244
+ @initialized = true
245
+ end
246
+ rescue Timeout::Error
247
+ raise InitializationError, "Initialization timed out after #{@config.init_timeout}s"
248
+ rescue InitializationError
249
+ raise
250
+ rescue StandardError => e
251
+ raise InitializationError, "Failed to initialize: #{e.message}"
252
+ end
253
+
254
+ def start_data_source
255
+ return if @closed
256
+
257
+ if @config.streaming
258
+ start_streaming
259
+ else
260
+ start_polling
261
+ end
262
+ end
263
+
264
+ def start_streaming
265
+ @streaming_handler = DataSource::StreamingHandler.new(
266
+ sdk_key: @sdk_key,
267
+ config: @config,
268
+ http_client: @http_client,
269
+ on_flag_updated: ->(flag) { @store.upsert(flag) },
270
+ on_flag_deleted: ->(key) { @store.remove_flag(key) },
271
+ on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
272
+ on_error: ->(_err) { },
273
+ on_give_up: -> { fallback_to_polling }
274
+ )
275
+ @streaming_handler.start
276
+ end
277
+
278
+ def fallback_to_polling
279
+ @config.logger&.warn("Featureflip: streaming retries exhausted, falling back to polling")
280
+ @streaming_handler = nil
281
+ start_polling
282
+ end
283
+
284
+ def start_polling
285
+ @polling_handler = DataSource::PollingHandler.new(
286
+ http_client: @http_client,
287
+ config: @config,
288
+ on_update: ->(flags, segments) { @store.init(flags, segments) },
289
+ on_error: ->(_err) { }
290
+ )
291
+ @polling_handler.start
292
+ end
293
+
294
+ def start_event_processor
295
+ @event_processor = Events::EventProcessor.new(
296
+ @http_client,
297
+ flush_interval: @config.flush_interval,
298
+ flush_batch_size: @config.flush_batch_size
299
+ )
300
+ @event_processor.start
301
+ end
302
+
303
+ def evaluate_flag(key, context, default_value)
304
+ if @test_mode
305
+ return @test_values.fetch(key, default_value)
306
+ end
307
+
308
+ detail = variation_detail(key, context, default_value)
309
+ detail.value
310
+ rescue StandardError
311
+ default_value
312
+ end
313
+
314
+ def get_segment(key)
315
+ @store.get_segment(key)
316
+ end
317
+
318
+ def normalize_context(context)
319
+ return {} if context.nil?
320
+ context.transform_keys(&:to_s)
321
+ end
322
+
323
+ def record_evaluation(key, context, variation_key)
324
+ return unless @event_processor
325
+
326
+ @event_processor.queue_event({
327
+ type: "Evaluation",
328
+ flagKey: key,
329
+ userId: context["user_id"]&.to_s,
330
+ variation: variation_key,
331
+ timestamp: Time.now.utc.iso8601
332
+ })
333
+ end
334
+
335
+ def init_test_mode(flags)
336
+ @sdk_key = "test-key"
337
+ @config = Config.new
338
+ @store = Store::FlagStore.new
339
+ @evaluator = Evaluation::Evaluator.new
340
+ @initialized = true
341
+ @closed = false
342
+ @test_mode = true
343
+ @test_values = flags.dup
344
+ @http_client = nil
345
+ @streaming_handler = nil
346
+ @polling_handler = nil
347
+ @event_processor = nil
348
+ @ref_count = 1
349
+ @ref_mutex = Mutex.new
350
+ @shut_down = false
351
+ end
352
+
353
+ def self._configs_equal(a, b)
354
+ a.base_url == b.base_url &&
355
+ a.streaming == b.streaming &&
356
+ a.poll_interval == b.poll_interval &&
357
+ a.flush_interval == b.flush_interval &&
358
+ a.flush_batch_size == b.flush_batch_size &&
359
+ a.init_timeout == b.init_timeout &&
360
+ a.connect_timeout == b.connect_timeout &&
361
+ a.read_timeout == b.read_timeout &&
362
+ a.send_events == b.send_events
363
+ end
364
+
365
+ private_class_method :_configs_equal
366
+ end
367
+ end
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "1.0.1"
2
+ VERSION = "2.0.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.0.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-04-11 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