togglecraft 1.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 +7 -0
- data/CHANGELOG.md +108 -0
- data/LICENSE +18 -0
- data/README.md +347 -0
- data/lib/togglecraft/cache.rb +151 -0
- data/lib/togglecraft/cache_adapters/memory_adapter.rb +54 -0
- data/lib/togglecraft/client.rb +568 -0
- data/lib/togglecraft/connection_pool.rb +134 -0
- data/lib/togglecraft/evaluator.rb +309 -0
- data/lib/togglecraft/shared_sse_connection.rb +266 -0
- data/lib/togglecraft/sse_connection.rb +296 -0
- data/lib/togglecraft/utils.rb +179 -0
- data/lib/togglecraft/version.rb +5 -0
- data/lib/togglecraft.rb +19 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/togglecraft/cache/memory_adapter_spec.rb +128 -0
- data/spec/togglecraft/cache_spec.rb +274 -0
- data/spec/togglecraft/client_spec.rb +728 -0
- data/spec/togglecraft/connection_pool_spec.rb +178 -0
- data/spec/togglecraft/evaluator_spec.rb +443 -0
- data/spec/togglecraft/shared_sse_connection_spec.rb +585 -0
- data/spec/togglecraft/sse_connection_spec.rb +691 -0
- data/spec/togglecraft/utils_spec.rb +506 -0
- metadata +151 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToggleCraft
|
|
4
|
+
# Main ToggleCraft client class
|
|
5
|
+
# Provides the primary interface for feature flag evaluation with real-time updates
|
|
6
|
+
class Client
|
|
7
|
+
attr_reader :config, :ready
|
|
8
|
+
|
|
9
|
+
# Initialize the ToggleCraft client
|
|
10
|
+
# @param sdk_key [String] SDK key for authentication
|
|
11
|
+
# @param options [Hash] Configuration options
|
|
12
|
+
def initialize(sdk_key:, **options)
|
|
13
|
+
raise ArgumentError, 'sdk_key is required' if sdk_key.nil? || sdk_key.empty?
|
|
14
|
+
|
|
15
|
+
@config = {
|
|
16
|
+
url: 'https://sse.togglecraft.io',
|
|
17
|
+
enable_cache: true,
|
|
18
|
+
cache_adapter: :memory,
|
|
19
|
+
cache_ttl: 300, # 5 minutes
|
|
20
|
+
reconnect_interval: 1,
|
|
21
|
+
max_reconnect_interval: 30,
|
|
22
|
+
max_reconnect_attempts: 10,
|
|
23
|
+
slow_reconnect_interval: 60,
|
|
24
|
+
share_connection: true,
|
|
25
|
+
debug: false,
|
|
26
|
+
enable_rollout_stage_polling: true,
|
|
27
|
+
rollout_stage_check_interval: 60,
|
|
28
|
+
fetch_jitter: 1500, # milliseconds
|
|
29
|
+
api_domain: 'https://togglecraft.io'
|
|
30
|
+
}.merge(options).merge(sdk_key: sdk_key)
|
|
31
|
+
|
|
32
|
+
# Initialize components
|
|
33
|
+
@evaluator = Evaluator.new
|
|
34
|
+
@cache = @config[:enable_cache] ? Cache.new(adapter: @config[:cache_adapter], ttl: @config[:cache_ttl]) : nil
|
|
35
|
+
|
|
36
|
+
# State
|
|
37
|
+
@ready = Concurrent::AtomicBoolean.new(false)
|
|
38
|
+
@flags = Concurrent::Map.new
|
|
39
|
+
@current_version = Concurrent::AtomicReference.new(nil)
|
|
40
|
+
@project_key = Concurrent::AtomicReference.new(nil)
|
|
41
|
+
@environment_key = Concurrent::AtomicReference.new(nil)
|
|
42
|
+
@fetch_in_progress = Concurrent::AtomicBoolean.new(false)
|
|
43
|
+
|
|
44
|
+
# Event listeners
|
|
45
|
+
@listeners = Concurrent::Map.new
|
|
46
|
+
@listeners[:ready] = []
|
|
47
|
+
@listeners[:flags_updated] = []
|
|
48
|
+
@listeners[:error] = []
|
|
49
|
+
@listeners[:disconnected] = []
|
|
50
|
+
@listeners[:reconnecting] = []
|
|
51
|
+
@listeners[:rollout_stage_changed] = []
|
|
52
|
+
@listeners_mutex = Mutex.new
|
|
53
|
+
|
|
54
|
+
# Rollout stage polling
|
|
55
|
+
@rollout_stage_percentages = {}
|
|
56
|
+
@rollout_stage_timer = nil
|
|
57
|
+
|
|
58
|
+
# Initialize connection
|
|
59
|
+
initialize_connection
|
|
60
|
+
|
|
61
|
+
# Enable debug on connection pool if debug is enabled
|
|
62
|
+
ConnectionPool.set_debug(true) if @config[:debug] && @config[:share_connection]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Connect to SSE server
|
|
66
|
+
# @return [void]
|
|
67
|
+
def connect
|
|
68
|
+
# Try to load from cache first
|
|
69
|
+
if @cache
|
|
70
|
+
cached_flags = @cache.all_flags
|
|
71
|
+
if cached_flags.any?
|
|
72
|
+
@flags.clear
|
|
73
|
+
cached_flags.each { |k, v| @flags[k] = v }
|
|
74
|
+
@evaluator.update_flags(cached_flags)
|
|
75
|
+
log 'Loaded flags from cache'
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Connect to SSE server
|
|
80
|
+
if @using_shared_connection
|
|
81
|
+
@connection.connect(self)
|
|
82
|
+
else
|
|
83
|
+
@connection.connect
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
log 'Connected to ToggleCraft'
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
handle_error(e)
|
|
89
|
+
raise
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Disconnect from SSE server
|
|
93
|
+
# @return [void]
|
|
94
|
+
def disconnect
|
|
95
|
+
log 'Disconnecting from SSE'
|
|
96
|
+
stop_rollout_stage_polling
|
|
97
|
+
@connection.disconnect
|
|
98
|
+
@ready.make_false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Reconnect to SSE server
|
|
102
|
+
# @return [void]
|
|
103
|
+
def reconnect
|
|
104
|
+
emit(:reconnecting)
|
|
105
|
+
@connection.reconnect
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Check if client is ready
|
|
109
|
+
# @return [Boolean]
|
|
110
|
+
def ready?
|
|
111
|
+
@ready.value
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check if connected to SSE server
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def connected?
|
|
117
|
+
@connection&.connected? || false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Wait for client to be ready
|
|
121
|
+
# @param timeout [Integer] Timeout in seconds
|
|
122
|
+
# @return [void]
|
|
123
|
+
# @raise [Timeout::Error] if timeout is reached
|
|
124
|
+
def wait_for_ready(timeout: 5)
|
|
125
|
+
return if ready?
|
|
126
|
+
|
|
127
|
+
ready_latch = Concurrent::CountDownLatch.new(1)
|
|
128
|
+
ready_handler = -> { ready_latch.count_down }
|
|
129
|
+
|
|
130
|
+
once(:ready, &ready_handler)
|
|
131
|
+
|
|
132
|
+
return if ready_latch.wait(timeout)
|
|
133
|
+
|
|
134
|
+
off(:ready, ready_handler)
|
|
135
|
+
raise Timeout::Error, 'Timeout waiting for client to be ready'
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Evaluate a boolean flag
|
|
139
|
+
# @param flag_key [String] Flag key
|
|
140
|
+
# @param context [Hash] Evaluation context
|
|
141
|
+
# @param default [Boolean] Default value
|
|
142
|
+
# @return [Boolean]
|
|
143
|
+
def enabled?(flag_key, context = {}, default: false)
|
|
144
|
+
result = @evaluator.evaluate_boolean(flag_key, context, default)
|
|
145
|
+
|
|
146
|
+
# Cache the result
|
|
147
|
+
cache_evaluation(flag_key, context, result) if @cache && context
|
|
148
|
+
|
|
149
|
+
result
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Get variant for multivariate flag
|
|
153
|
+
# @param flag_key [String] Flag key
|
|
154
|
+
# @param context [Hash] Evaluation context
|
|
155
|
+
# @param default [String, nil] Default variant
|
|
156
|
+
# @return [String, nil]
|
|
157
|
+
def variant(flag_key, context = {}, default: nil)
|
|
158
|
+
result = @evaluator.evaluate_multivariate(flag_key, context, default)
|
|
159
|
+
|
|
160
|
+
# Cache the result
|
|
161
|
+
cache_evaluation(flag_key, context, result) if @cache && context
|
|
162
|
+
|
|
163
|
+
result
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Check if in percentage rollout
|
|
167
|
+
# @param flag_key [String] Flag key
|
|
168
|
+
# @param context [Hash] Evaluation context
|
|
169
|
+
# @param default [Boolean] Default value
|
|
170
|
+
# @return [Boolean]
|
|
171
|
+
def in_percentage?(flag_key, context = {}, default: false)
|
|
172
|
+
result = @evaluator.evaluate_percentage(flag_key, context, default)
|
|
173
|
+
|
|
174
|
+
# Cache the result
|
|
175
|
+
cache_evaluation(flag_key, context, result) if @cache && context
|
|
176
|
+
|
|
177
|
+
result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Get current percentage for a flag
|
|
181
|
+
# @param flag_key [String] Flag key
|
|
182
|
+
# @return [Integer, nil]
|
|
183
|
+
def percentage(flag_key)
|
|
184
|
+
@evaluator.percentage(flag_key)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Evaluate any flag type
|
|
188
|
+
# @param flag_key [String] Flag key
|
|
189
|
+
# @param context [Hash] Evaluation context
|
|
190
|
+
# @param default [Object] Default value
|
|
191
|
+
# @return [Object]
|
|
192
|
+
def evaluate(flag_key, context = {}, default: false)
|
|
193
|
+
@evaluator.evaluate(flag_key, context, default)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Get all flag keys
|
|
197
|
+
# @return [Array<String>]
|
|
198
|
+
def all_flag_keys
|
|
199
|
+
@evaluator.flag_keys
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Get flag metadata
|
|
203
|
+
# @param flag_key [String] Flag key
|
|
204
|
+
# @return [Hash, nil]
|
|
205
|
+
def flag_metadata(flag_key)
|
|
206
|
+
@evaluator.flag_metadata(flag_key)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Check if flag exists
|
|
210
|
+
# @param flag_key [String] Flag key
|
|
211
|
+
# @return [Boolean]
|
|
212
|
+
def has_flag?(flag_key)
|
|
213
|
+
@evaluator.has_flag?(flag_key)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Add event listener
|
|
217
|
+
# @param event [Symbol] Event type
|
|
218
|
+
# @param block [Proc] Event handler
|
|
219
|
+
# @return [void]
|
|
220
|
+
def on(event, &block)
|
|
221
|
+
@listeners_mutex.synchronize do
|
|
222
|
+
@listeners[event] ||= []
|
|
223
|
+
@listeners[event] << block
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Add one-time event listener
|
|
228
|
+
# @param event [Symbol] Event type
|
|
229
|
+
# @param block [Proc] Event handler
|
|
230
|
+
# @return [void]
|
|
231
|
+
def once(event, &block)
|
|
232
|
+
wrapper = lambda do |*args|
|
|
233
|
+
block.call(*args)
|
|
234
|
+
off(event, wrapper)
|
|
235
|
+
end
|
|
236
|
+
on(event, &wrapper)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Remove event listener
|
|
240
|
+
# @param event [Symbol] Event type
|
|
241
|
+
# @param callable [Proc, nil] Specific handler to remove
|
|
242
|
+
# @return [void]
|
|
243
|
+
def off(event, callable = nil)
|
|
244
|
+
@listeners_mutex.synchronize do
|
|
245
|
+
if callable
|
|
246
|
+
@listeners[event]&.delete(callable)
|
|
247
|
+
else
|
|
248
|
+
@listeners[event] = []
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Clean up resources
|
|
254
|
+
# @return [void]
|
|
255
|
+
def destroy
|
|
256
|
+
log 'Destroying client instance'
|
|
257
|
+
|
|
258
|
+
stop_rollout_stage_polling
|
|
259
|
+
|
|
260
|
+
# Handle cleanup based on connection type
|
|
261
|
+
if @using_shared_connection && @connection
|
|
262
|
+
@connection.remove_client(self)
|
|
263
|
+
elsif @connection
|
|
264
|
+
disconnect
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
@cache&.destroy
|
|
268
|
+
|
|
269
|
+
# Clear all listeners
|
|
270
|
+
@listeners_mutex.synchronize do
|
|
271
|
+
@listeners.each_key { |key| @listeners[key] = [] }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
@connection = nil
|
|
275
|
+
@flags.clear
|
|
276
|
+
@ready.make_false
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Handle SSE connection event
|
|
280
|
+
# @return [void]
|
|
281
|
+
def handle_connect
|
|
282
|
+
log 'SSE connected'
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Handle SSE disconnection event
|
|
286
|
+
# @return [void]
|
|
287
|
+
def handle_disconnect
|
|
288
|
+
@ready.make_false
|
|
289
|
+
emit(:disconnected)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Handle error
|
|
293
|
+
# @param error [Exception] Error that occurred
|
|
294
|
+
# @return [void]
|
|
295
|
+
def handle_error(error)
|
|
296
|
+
log "Error: #{error.message}"
|
|
297
|
+
emit(:error, error)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Handle flags update from SSE
|
|
301
|
+
# @param payload [Hash] Message payload
|
|
302
|
+
# @return [void]
|
|
303
|
+
def handle_flags_update(payload)
|
|
304
|
+
log "Received message: #{payload}"
|
|
305
|
+
|
|
306
|
+
# Handle version update notification
|
|
307
|
+
if payload[:type] == 'version_update'
|
|
308
|
+
handle_version_update(payload)
|
|
309
|
+
return
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Handle full flags payload
|
|
313
|
+
process_flags_payload(payload) if payload[:flags]
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
private
|
|
317
|
+
|
|
318
|
+
# Initialize connection (shared or dedicated)
|
|
319
|
+
# @return [void]
|
|
320
|
+
def initialize_connection
|
|
321
|
+
# Try to get a shared connection from the pool
|
|
322
|
+
shared_connection = ConnectionPool.get_connection(@config)
|
|
323
|
+
|
|
324
|
+
if shared_connection
|
|
325
|
+
@connection = shared_connection
|
|
326
|
+
@using_shared_connection = true
|
|
327
|
+
log 'Using shared SSE connection'
|
|
328
|
+
else
|
|
329
|
+
# Create dedicated SSE connection
|
|
330
|
+
@connection = SSEConnection.new(
|
|
331
|
+
url: @config[:url],
|
|
332
|
+
sdk_key: @config[:sdk_key],
|
|
333
|
+
reconnect_interval: @config[:reconnect_interval],
|
|
334
|
+
max_reconnect_interval: @config[:max_reconnect_interval],
|
|
335
|
+
max_reconnect_attempts: @config[:max_reconnect_attempts],
|
|
336
|
+
slow_reconnect_interval: @config[:slow_reconnect_interval],
|
|
337
|
+
heartbeat_domain: @config[:heartbeat_domain],
|
|
338
|
+
debug: @config[:debug],
|
|
339
|
+
on_message: method(:handle_flags_update),
|
|
340
|
+
on_connect: method(:handle_connect),
|
|
341
|
+
on_disconnect: method(:handle_disconnect),
|
|
342
|
+
on_error: method(:handle_error)
|
|
343
|
+
)
|
|
344
|
+
@using_shared_connection = false
|
|
345
|
+
log 'Using dedicated SSE connection'
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Handle version update notification
|
|
350
|
+
# @param data [Hash] Version update data
|
|
351
|
+
# @return [void]
|
|
352
|
+
def handle_version_update(data)
|
|
353
|
+
version = data[:version]
|
|
354
|
+
project_key = data[:project_key]
|
|
355
|
+
environment_key = data[:environment_key]
|
|
356
|
+
|
|
357
|
+
# Store project and environment keys from first message
|
|
358
|
+
@project_key.set(project_key) if project_key && @project_key.get.nil?
|
|
359
|
+
@environment_key.set(environment_key) if environment_key && @environment_key.get.nil?
|
|
360
|
+
|
|
361
|
+
# Check if we already have this version
|
|
362
|
+
if @current_version.get == version
|
|
363
|
+
log "Already have version #{version}"
|
|
364
|
+
return
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
log "New version available: old=#{@current_version.get}, new=#{version}, changed_flag=#{data[:changed_flag]}"
|
|
368
|
+
|
|
369
|
+
# Apply jitter to prevent thundering herd (only on updates, not initial connection)
|
|
370
|
+
is_initial_connection = @current_version.get.nil?
|
|
371
|
+
jitter = is_initial_connection ? 0 : Utils.generate_jitter(@config[:fetch_jitter])
|
|
372
|
+
|
|
373
|
+
if jitter.positive?
|
|
374
|
+
log "Applying #{jitter}ms jitter before fetching"
|
|
375
|
+
sleep(jitter.to_f / 1000)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Fetch new version via HTTP
|
|
379
|
+
fetch_flags_via_http(version)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Fetch flags via HTTP using versioned URL
|
|
383
|
+
# @param version [Integer] Version to fetch
|
|
384
|
+
# @return [void]
|
|
385
|
+
def fetch_flags_via_http(version)
|
|
386
|
+
return if @fetch_in_progress.true?
|
|
387
|
+
|
|
388
|
+
unless @project_key.get && @environment_key.get
|
|
389
|
+
log 'Project/environment keys not available yet, cannot fetch'
|
|
390
|
+
return
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
@fetch_in_progress.make_true
|
|
394
|
+
|
|
395
|
+
begin
|
|
396
|
+
versioned_url = "#{@config[:api_domain]}/api/v1/flags/#{@project_key.get}/#{@environment_key.get}/v/#{version}"
|
|
397
|
+
log "Fetching flags from #{versioned_url}"
|
|
398
|
+
|
|
399
|
+
response = HTTP.timeout(10).get(versioned_url)
|
|
400
|
+
|
|
401
|
+
raise "HTTP #{response.status}: #{response.status.reason}" unless response.status.success?
|
|
402
|
+
|
|
403
|
+
payload = JSON.parse(response.body.to_s, symbolize_names: true)
|
|
404
|
+
process_flags_payload(payload)
|
|
405
|
+
|
|
406
|
+
log "Flags updated via HTTP: version=#{payload[:version]}, flagCount=#{payload[:flags]&.size || 0}"
|
|
407
|
+
rescue StandardError => e
|
|
408
|
+
log "Failed to fetch flags via HTTP: #{e.message}"
|
|
409
|
+
handle_error(e)
|
|
410
|
+
ensure
|
|
411
|
+
@fetch_in_progress.make_false
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Process a full flags payload
|
|
416
|
+
# @param payload [Hash] Flags payload
|
|
417
|
+
# @return [void]
|
|
418
|
+
def process_flags_payload(payload)
|
|
419
|
+
log 'Processing flags payload'
|
|
420
|
+
|
|
421
|
+
@flags.clear
|
|
422
|
+
payload[:flags]&.each { |k, v| @flags[k] = v }
|
|
423
|
+
@evaluator.update_flags(payload[:flags] || {})
|
|
424
|
+
|
|
425
|
+
# Update version tracking
|
|
426
|
+
@current_version.set(payload[:version]) if payload[:version]
|
|
427
|
+
|
|
428
|
+
# Update cache
|
|
429
|
+
@cache&.set_all_flags(payload[:flags] || {})
|
|
430
|
+
|
|
431
|
+
# Initialize rollout stage tracking
|
|
432
|
+
initialize_rollout_stage_tracking
|
|
433
|
+
|
|
434
|
+
# Mark as ready on first update
|
|
435
|
+
unless @ready.value
|
|
436
|
+
@ready.make_true
|
|
437
|
+
emit(:ready)
|
|
438
|
+
|
|
439
|
+
# Start rollout stage polling
|
|
440
|
+
start_rollout_stage_polling if @config[:enable_rollout_stage_polling]
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Emit flags updated event
|
|
444
|
+
emit(:flags_updated, payload[:flags] || {})
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Initialize rollout stage tracking
|
|
448
|
+
# @return [void]
|
|
449
|
+
def initialize_rollout_stage_tracking
|
|
450
|
+
@rollout_stage_percentages = {}
|
|
451
|
+
|
|
452
|
+
@flags.each do |flag_key, flag|
|
|
453
|
+
next unless flag[:type] == 'percentage'
|
|
454
|
+
next unless flag[:rollout_stages]&.any?
|
|
455
|
+
|
|
456
|
+
@rollout_stage_percentages[flag_key] = @evaluator.current_percentage_for_flag(flag)
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Start rollout stage polling
|
|
461
|
+
# @return [void]
|
|
462
|
+
def start_rollout_stage_polling
|
|
463
|
+
stop_rollout_stage_polling
|
|
464
|
+
|
|
465
|
+
log "Starting rollout stage polling (interval: #{@config[:rollout_stage_check_interval]}s)"
|
|
466
|
+
|
|
467
|
+
@rollout_stage_timer = Thread.new do
|
|
468
|
+
loop do
|
|
469
|
+
sleep @config[:rollout_stage_check_interval]
|
|
470
|
+
check_rollout_stage_changes
|
|
471
|
+
end
|
|
472
|
+
rescue StandardError => e
|
|
473
|
+
log "Rollout stage polling error: #{e.message}"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
@rollout_stage_timer.abort_on_exception = false
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Stop rollout stage polling
|
|
480
|
+
# @return [void]
|
|
481
|
+
def stop_rollout_stage_polling
|
|
482
|
+
return unless @rollout_stage_timer
|
|
483
|
+
|
|
484
|
+
@rollout_stage_timer.kill if @rollout_stage_timer.alive?
|
|
485
|
+
@rollout_stage_timer = nil
|
|
486
|
+
log 'Stopped rollout stage polling'
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Check for rollout stage changes
|
|
490
|
+
# @return [void]
|
|
491
|
+
def check_rollout_stage_changes
|
|
492
|
+
changed_flags = []
|
|
493
|
+
|
|
494
|
+
@flags.each do |flag_key, flag|
|
|
495
|
+
next unless flag[:type] == 'percentage'
|
|
496
|
+
next unless flag[:rollout_stages]&.any?
|
|
497
|
+
|
|
498
|
+
old_percentage = @rollout_stage_percentages[flag_key]
|
|
499
|
+
new_percentage = @evaluator.current_percentage_for_flag(flag)
|
|
500
|
+
|
|
501
|
+
next unless old_percentage != new_percentage
|
|
502
|
+
|
|
503
|
+
@rollout_stage_percentages[flag_key] = new_percentage
|
|
504
|
+
changed_flags << {
|
|
505
|
+
key: flag_key,
|
|
506
|
+
old_percentage: old_percentage,
|
|
507
|
+
new_percentage: new_percentage
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
log "Rollout stage changed for #{flag_key}: #{old_percentage}% → #{new_percentage}%"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
return unless changed_flags.any?
|
|
514
|
+
|
|
515
|
+
# Clear evaluation cache for changed flags
|
|
516
|
+
clear_evaluation_cache_for_flags(changed_flags.map { |f| f[:key] })
|
|
517
|
+
|
|
518
|
+
# Emit rollout stage changed event
|
|
519
|
+
emit(:rollout_stage_changed, {
|
|
520
|
+
flags: changed_flags,
|
|
521
|
+
timestamp: Time.now.iso8601
|
|
522
|
+
})
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Clear evaluation cache for specific flags
|
|
526
|
+
# @param flag_keys [Array<String>] Flag keys to clear cache for
|
|
527
|
+
# @return [void]
|
|
528
|
+
def clear_evaluation_cache_for_flags(flag_keys)
|
|
529
|
+
return unless @cache
|
|
530
|
+
|
|
531
|
+
log "Clearing evaluation cache for flags: #{flag_keys.join(', ')}"
|
|
532
|
+
flag_keys.each { |key| @cache.clear_by_prefix("eval:#{key}:") }
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Cache an evaluation result
|
|
536
|
+
# @param flag_key [String] Flag key
|
|
537
|
+
# @param context [Hash] Evaluation context
|
|
538
|
+
# @param result [Object] Evaluation result
|
|
539
|
+
# @return [void]
|
|
540
|
+
def cache_evaluation(flag_key, context, result)
|
|
541
|
+
cache_key = Utils.create_cache_key(flag_key, context)
|
|
542
|
+
@cache.set("eval:#{cache_key}", result, ttl: 60) # Cache for 1 minute
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Emit an event
|
|
546
|
+
# @param event [Symbol] Event type
|
|
547
|
+
# @param args [Array] Event arguments
|
|
548
|
+
# @return [void]
|
|
549
|
+
def emit(event, *args)
|
|
550
|
+
handlers = @listeners_mutex.synchronize { @listeners[event]&.dup || [] }
|
|
551
|
+
|
|
552
|
+
handlers.each do |handler|
|
|
553
|
+
handler.call(*args)
|
|
554
|
+
rescue StandardError => e
|
|
555
|
+
warn "Error in #{event} listener: #{e.message}"
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Log debug message
|
|
560
|
+
# @param message [String] Message to log
|
|
561
|
+
# @return [void]
|
|
562
|
+
def log(message)
|
|
563
|
+
return unless @config[:debug]
|
|
564
|
+
|
|
565
|
+
puts "[ToggleCraft Client] #{message}"
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToggleCraft
|
|
4
|
+
# Connection pool manager for SSE connections
|
|
5
|
+
# Ensures that multiple SDK instances can share the same SSE connection
|
|
6
|
+
class ConnectionPool
|
|
7
|
+
class << self
|
|
8
|
+
# Get the connections map
|
|
9
|
+
# @return [Concurrent::Map]
|
|
10
|
+
def connections
|
|
11
|
+
@connections ||= Concurrent::Map.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Get or create a shared connection
|
|
15
|
+
# @param config [Hash] Client configuration
|
|
16
|
+
# @return [SharedSSEConnection, nil] Shared connection instance or nil if sharing disabled
|
|
17
|
+
def get_connection(config)
|
|
18
|
+
# If connection sharing is disabled, return nil to create a dedicated connection
|
|
19
|
+
unless config[:share_connection]
|
|
20
|
+
log 'Connection sharing disabled for this client'
|
|
21
|
+
return nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
pool_key = generate_pool_key(config)
|
|
25
|
+
log "Getting connection for pool key: #{pool_key}"
|
|
26
|
+
|
|
27
|
+
# Check if connection already exists
|
|
28
|
+
connection = connections[pool_key]
|
|
29
|
+
|
|
30
|
+
if connection
|
|
31
|
+
log "Reusing existing connection for #{pool_key} (#{connection.client_count} clients)"
|
|
32
|
+
else
|
|
33
|
+
# Create new shared SSE connection
|
|
34
|
+
log "Creating new shared SSE connection for #{pool_key}"
|
|
35
|
+
connection = SharedSSEConnection.new(config, pool_key)
|
|
36
|
+
connections[pool_key] = connection
|
|
37
|
+
|
|
38
|
+
# Set up cleanup when connection has no more clients
|
|
39
|
+
connection.on_empty do
|
|
40
|
+
log "Removing empty connection for #{pool_key}"
|
|
41
|
+
connections.delete(pool_key)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
connection
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Generate a pool key from configuration
|
|
49
|
+
# @param config [Hash] Client configuration
|
|
50
|
+
# @return [String] Pool key for the connection
|
|
51
|
+
def generate_pool_key(config)
|
|
52
|
+
# Allow custom pool key for advanced users
|
|
53
|
+
return config[:connection_pool_key] if config[:connection_pool_key]
|
|
54
|
+
|
|
55
|
+
# Generate key from connection parameters
|
|
56
|
+
url = config[:url] || 'https://sse.togglecraft.io'
|
|
57
|
+
sdk_key = config[:sdk_key]
|
|
58
|
+
"#{url}:#{sdk_key}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get statistics about the connection pool
|
|
62
|
+
# @return [Hash] Pool statistics
|
|
63
|
+
def stats
|
|
64
|
+
conn_stats = []
|
|
65
|
+
connections.each do |key, conn|
|
|
66
|
+
conn_stats << {
|
|
67
|
+
key: key,
|
|
68
|
+
client_count: conn.client_count,
|
|
69
|
+
connected: conn.connected?
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
total_connections: connections.size,
|
|
75
|
+
connections: conn_stats
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Clear all connections
|
|
80
|
+
# @param force [Boolean] Force disconnect all connections
|
|
81
|
+
# @return [void]
|
|
82
|
+
def clear(force: false)
|
|
83
|
+
connections.each_value(&:force_disconnect) if force
|
|
84
|
+
connections.clear
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Enable or disable debug logging
|
|
88
|
+
# @param enabled [Boolean]
|
|
89
|
+
# @return [void]
|
|
90
|
+
def set_debug(enabled)
|
|
91
|
+
@debug = enabled
|
|
92
|
+
# Also enable debug on existing connections
|
|
93
|
+
connections.each_value { |conn| conn.set_debug(enabled) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get the number of active connections
|
|
97
|
+
# @return [Integer]
|
|
98
|
+
def connection_count
|
|
99
|
+
connections.size
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get the total number of clients across all connections
|
|
103
|
+
# @return [Integer]
|
|
104
|
+
def total_client_count
|
|
105
|
+
connections.values.sum(&:client_count)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Check if a specific pool key has an active connection
|
|
109
|
+
# @param pool_key [String]
|
|
110
|
+
# @return [Boolean]
|
|
111
|
+
def has_connection?(pool_key)
|
|
112
|
+
connections.key?(pool_key)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get connection by pool key (for testing/debugging)
|
|
116
|
+
# @param pool_key [String]
|
|
117
|
+
# @return [SharedSSEConnection, nil]
|
|
118
|
+
def get_connection_by_key(pool_key)
|
|
119
|
+
connections[pool_key]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# Log debug messages
|
|
125
|
+
# @param message [String] Message to log
|
|
126
|
+
# @return [void]
|
|
127
|
+
def log(message)
|
|
128
|
+
return unless @debug
|
|
129
|
+
|
|
130
|
+
puts "[ToggleCraft ConnectionPool] #{message}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|