magick-feature-flags 0.9.22 → 0.9.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +13 -4
- data/lib/magick/config.rb +38 -6
- data/lib/magick/performance_metrics.rb +70 -8
- data/lib/magick/rails/railtie.rb +7 -1
- data/lib/magick/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9aaecf68655a77ae48f4f7b2d7f760528a4aa14aa5f37b5f8a603897e3040d40
|
|
4
|
+
data.tar.gz: 8b9694162f06fded4597aaadd14334c515f996979161915004ef113a979aba38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f47eaddee984442954a6a9f556c434469f8f9c923c93daa4971e232ff0b8ee248c96a510c505c3caecd99ed38687ec2a27544d8624837030a767b13b6253d0f
|
|
7
|
+
data.tar.gz: e2523e3c4051c6922638bdf6739eb3dc89e92822a1a93b0f542d6827bb5706a59d8de3bf2d56d1125584d5e20c016f92b3314b9f1f6ac047cab0ce828b00ad7d
|
data/README.md
CHANGED
|
@@ -10,7 +10,7 @@ A performant and memory-efficient feature toggle gem for Ruby and Rails applicat
|
|
|
10
10
|
- **Rails Integration**: Seamless integration with Rails, including request store caching
|
|
11
11
|
- **DSL Support**: Define features in a Ruby DSL file (`config/features.rb`)
|
|
12
12
|
- **Thread-Safe**: All operations are thread-safe for concurrent access
|
|
13
|
-
- **Performance**:
|
|
13
|
+
- **Performance**: Lightning-fast feature checks with async metrics recording and memory-first caching strategy
|
|
14
14
|
- **Advanced Features**: Circuit breaker, audit logging, performance metrics, versioning, and more
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
@@ -52,7 +52,8 @@ The generator creates `config/initializers/magick.rb` with sensible defaults. Yo
|
|
|
52
52
|
```ruby
|
|
53
53
|
Magick.configure do
|
|
54
54
|
# Configure Redis (optional)
|
|
55
|
-
|
|
55
|
+
# Use database 1 by default to avoid conflicts with Rails cache (which uses DB 0)
|
|
56
|
+
redis url: ENV['REDIS_URL'], db: 1
|
|
56
57
|
|
|
57
58
|
# Enable features
|
|
58
59
|
performance_metrics enabled: true
|
|
@@ -73,7 +74,10 @@ Magick.configure do
|
|
|
73
74
|
memory_ttl 7200 # 2 hours
|
|
74
75
|
|
|
75
76
|
# Redis configuration
|
|
76
|
-
|
|
77
|
+
# Use separate database (DB 1) to avoid conflicts with Rails cache (DB 0)
|
|
78
|
+
# This ensures feature toggles persist even when Rails cache is cleared
|
|
79
|
+
redis url: ENV['REDIS_URL'], namespace: 'magick:features', db: 1
|
|
80
|
+
# Or include database in URL: redis url: 'redis://localhost:6379/1'
|
|
77
81
|
|
|
78
82
|
# Circuit breaker settings
|
|
79
83
|
circuit_breaker threshold: 5, timeout: 60
|
|
@@ -383,7 +387,9 @@ Magick.configure do
|
|
|
383
387
|
end
|
|
384
388
|
```
|
|
385
389
|
|
|
386
|
-
**
|
|
390
|
+
**Performance:** Metrics are recorded asynchronously in a background thread, ensuring zero overhead on feature checks. The `enabled?` method remains lightning-fast even with metrics enabled.
|
|
391
|
+
|
|
392
|
+
**Note:** When `redis_tracking: true` is set, usage counts are persisted to Redis and aggregated across all processes, giving you total usage statistics. Metrics are automatically flushed in batches to minimize Redis overhead.
|
|
387
393
|
|
|
388
394
|
#### Audit Logging
|
|
389
395
|
|
|
@@ -424,6 +430,9 @@ With Redis configured:
|
|
|
424
430
|
- ✅ Persistent storage across restarts
|
|
425
431
|
- ✅ Zero Redis calls on feature checks (only memory lookups)
|
|
426
432
|
- ✅ Automatic cache invalidation when features change in any process
|
|
433
|
+
- ✅ **Isolated from Rails cache** - Use `db: 1` to store feature toggles in a separate Redis database, ensuring they persist even when Rails cache is cleared
|
|
434
|
+
|
|
435
|
+
**Important:** By default, Magick uses Redis database 1 to avoid conflicts with Rails cache (which typically uses database 0). This ensures that clearing Rails cache (`Rails.cache.clear`) won't affect your feature toggle states.
|
|
427
436
|
|
|
428
437
|
### Feature Types
|
|
429
438
|
|
data/lib/magick/config.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Magick
|
|
4
4
|
class Config
|
|
5
5
|
attr_accessor :adapter_registry, :performance_metrics, :audit_log, :versioning, :warn_on_deprecated,
|
|
6
|
-
:async_updates, :memory_ttl, :circuit_breaker_threshold, :circuit_breaker_timeout, :redis_url, :redis_namespace, :environment
|
|
6
|
+
:async_updates, :memory_ttl, :circuit_breaker_threshold, :circuit_breaker_timeout, :redis_url, :redis_namespace, :redis_db, :environment
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
@warn_on_deprecated = false
|
|
@@ -12,6 +12,7 @@ module Magick
|
|
|
12
12
|
@circuit_breaker_threshold = 5
|
|
13
13
|
@circuit_breaker_timeout = 60
|
|
14
14
|
@redis_namespace = 'magick:features'
|
|
15
|
+
@redis_db = nil # Use default database (0) unless specified
|
|
15
16
|
@environment = defined?(Rails) ? Rails.env.to_s : 'development'
|
|
16
17
|
end
|
|
17
18
|
|
|
@@ -38,10 +39,11 @@ module Magick
|
|
|
38
39
|
configure_memory_adapter(**options)
|
|
39
40
|
end
|
|
40
41
|
|
|
41
|
-
def redis(url: nil, namespace: nil, **options)
|
|
42
|
+
def redis(url: nil, namespace: nil, db: nil, **options)
|
|
42
43
|
@redis_url = url if url
|
|
43
44
|
@redis_namespace = namespace if namespace
|
|
44
|
-
|
|
45
|
+
@redis_db = db if db
|
|
46
|
+
redis_adapter = configure_redis_adapter(url: url, namespace: namespace, db: db, **options)
|
|
45
47
|
|
|
46
48
|
# Automatically create Registry adapter if it doesn't exist
|
|
47
49
|
# This allows users to just call `redis url: ...` without needing to call `adapter :registry`
|
|
@@ -161,17 +163,33 @@ module Magick
|
|
|
161
163
|
adapter
|
|
162
164
|
end
|
|
163
165
|
|
|
164
|
-
def configure_redis_adapter(url: nil, namespace: nil, client: nil)
|
|
166
|
+
def configure_redis_adapter(url: nil, namespace: nil, db: nil, client: nil)
|
|
165
167
|
return nil unless defined?(Redis)
|
|
166
168
|
|
|
167
169
|
url ||= @redis_url
|
|
168
170
|
namespace ||= @redis_namespace
|
|
171
|
+
db ||= @redis_db
|
|
169
172
|
|
|
170
173
|
redis_client = client || begin
|
|
174
|
+
redis_options = {}
|
|
175
|
+
|
|
171
176
|
if url
|
|
172
|
-
|
|
177
|
+
# Parse URL to extract database number if present
|
|
178
|
+
parsed_url = URI.parse(url) rescue nil
|
|
179
|
+
db_from_url = nil
|
|
180
|
+
if parsed_url && parsed_url.path && parsed_url.path.length > 1
|
|
181
|
+
# Redis URL format: redis://host:port/db_number
|
|
182
|
+
db_from_url = parsed_url.path[1..-1].to_i
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Use db parameter if provided, otherwise use db from URL, otherwise nil (default DB 0)
|
|
186
|
+
final_db = db || db_from_url
|
|
187
|
+
redis_options[:db] = final_db if final_db
|
|
188
|
+
redis_options[:url] = url
|
|
189
|
+
::Redis.new(redis_options)
|
|
173
190
|
else
|
|
174
|
-
|
|
191
|
+
redis_options[:db] = db if db
|
|
192
|
+
::Redis.new(redis_options)
|
|
175
193
|
end
|
|
176
194
|
rescue StandardError
|
|
177
195
|
nil
|
|
@@ -179,6 +197,20 @@ module Magick
|
|
|
179
197
|
|
|
180
198
|
return nil unless redis_client
|
|
181
199
|
|
|
200
|
+
# If db was specified but not in URL, select it explicitly
|
|
201
|
+
# This handles cases where URL doesn't include db number
|
|
202
|
+
if db && url
|
|
203
|
+
parsed_url = URI.parse(url) rescue nil
|
|
204
|
+
url_has_db = parsed_url && parsed_url.path && parsed_url.path.length > 1
|
|
205
|
+
unless url_has_db
|
|
206
|
+
begin
|
|
207
|
+
redis_client.select(db)
|
|
208
|
+
rescue StandardError
|
|
209
|
+
# Ignore if SELECT fails (some Redis setups don't support SELECT, e.g., Redis Cluster)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
182
214
|
adapter = Adapters::Redis.new(redis_client)
|
|
183
215
|
adapter.instance_variable_set(:@namespace, namespace) if namespace
|
|
184
216
|
adapter
|
|
@@ -40,23 +40,43 @@ module Magick
|
|
|
40
40
|
@_rails_events_enabled = defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
41
41
|
@_adapter_available = nil # Will be cached on first check
|
|
42
42
|
@_redis_available = nil # Will be cached on first check
|
|
43
|
+
|
|
44
|
+
# Async recording queue for non-blocking metrics
|
|
45
|
+
@async_queue = Queue.new
|
|
46
|
+
@async_thread = nil
|
|
47
|
+
@async_enabled = true # Enable async by default for performance
|
|
48
|
+
start_async_processor
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
# Public accessor for redis_enabled
|
|
46
52
|
attr_reader :redis_enabled
|
|
47
53
|
|
|
48
54
|
def record(feature_name, operation, duration, success: true)
|
|
49
|
-
# Fast path:
|
|
50
|
-
|
|
55
|
+
# Fast path: push to async queue (non-blocking, zero overhead in hot path)
|
|
56
|
+
# Queue#<< is thread-safe and lock-free - extremely fast!
|
|
57
|
+
return unless @async_enabled
|
|
58
|
+
|
|
59
|
+
# Push to async queue - this is lock-free and extremely fast
|
|
60
|
+
# Use non-blocking push (will raise if queue is full, but our queue is unbounded)
|
|
61
|
+
begin
|
|
62
|
+
@async_queue << [feature_name.to_s, operation.to_s, duration, success]
|
|
63
|
+
rescue ThreadError, ClosedQueueError
|
|
64
|
+
# Queue is closed or thread died, disable async
|
|
65
|
+
@async_enabled = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
51
70
|
|
|
71
|
+
# Internal: Process metrics from async queue (runs in background thread)
|
|
72
|
+
def process_async_record(feature_name_str, operation_str, duration, success)
|
|
52
73
|
# Minimize mutex lock time - only update counters
|
|
53
74
|
pending_count = nil
|
|
54
75
|
total_pending = nil
|
|
55
76
|
@mutex.synchronize do
|
|
56
77
|
# Only create Metric object if we're keeping metrics in memory
|
|
57
|
-
# Most of the time we just need counters, not full metric objects
|
|
58
78
|
if @metrics.length < 1000
|
|
59
|
-
metric = Metric.new(feature_name_str,
|
|
79
|
+
metric = Metric.new(feature_name_str, operation_str, duration, success: success)
|
|
60
80
|
@metrics << metric
|
|
61
81
|
end
|
|
62
82
|
@usage_count[feature_name_str] += 1
|
|
@@ -69,14 +89,48 @@ module Magick
|
|
|
69
89
|
|
|
70
90
|
# Rails 8+ event for usage tracking (cached check)
|
|
71
91
|
if @_rails_events_enabled
|
|
72
|
-
Magick::Rails::Events.usage_tracked(
|
|
92
|
+
Magick::Rails::Events.usage_tracked(feature_name_str, operation: operation_str, duration: duration,
|
|
93
|
+
success: success)
|
|
73
94
|
end
|
|
74
95
|
|
|
75
|
-
# Batch flush check - only if we're close to batch size
|
|
76
|
-
# Check pending count first before doing expensive adapter checks
|
|
96
|
+
# Batch flush check - only if we're close to batch size
|
|
77
97
|
flush_to_redis_if_needed if pending_count >= @batch_size || total_pending >= @batch_size
|
|
98
|
+
end
|
|
78
99
|
|
|
79
|
-
|
|
100
|
+
# Start background thread to process async metrics
|
|
101
|
+
def start_async_processor
|
|
102
|
+
return if @async_thread&.alive?
|
|
103
|
+
|
|
104
|
+
@async_thread = Thread.new do
|
|
105
|
+
last_flush_check = Time.now
|
|
106
|
+
loop do
|
|
107
|
+
# Wait for metrics with timeout to allow periodic flush checks
|
|
108
|
+
# Queue#pop with timeout returns nil on timeout, raises on closed queue
|
|
109
|
+
begin
|
|
110
|
+
data = @async_queue.pop(timeout: 1.0)
|
|
111
|
+
rescue ThreadError => e
|
|
112
|
+
# Queue closed or thread interrupted
|
|
113
|
+
break if e.message.include?('queue closed')
|
|
114
|
+
|
|
115
|
+
raise
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if data
|
|
119
|
+
feature_name_str, operation_str, duration, success = data
|
|
120
|
+
process_async_record(feature_name_str, operation_str, duration, success)
|
|
121
|
+
last_flush_check = Time.now
|
|
122
|
+
elsif Time.now - last_flush_check >= 1.0
|
|
123
|
+
# Timeout - check if we need to flush based on time (every second)
|
|
124
|
+
flush_to_redis_if_needed
|
|
125
|
+
last_flush_check = Time.now
|
|
126
|
+
end
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
# Log error but continue processing
|
|
129
|
+
warn "Magick: Error in async metrics processor: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
130
|
+
sleep 0.1 # Brief pause on error
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
@async_thread.abort_on_exception = false
|
|
80
134
|
end
|
|
81
135
|
|
|
82
136
|
def flush_to_redis_if_needed
|
|
@@ -326,5 +380,13 @@ module Magick
|
|
|
326
380
|
@flushed_counts.clear
|
|
327
381
|
end
|
|
328
382
|
end
|
|
383
|
+
|
|
384
|
+
# Stop async processor (for cleanup)
|
|
385
|
+
def stop_async_processor
|
|
386
|
+
@async_enabled = false
|
|
387
|
+
@async_queue.close if @async_queue.respond_to?(:close)
|
|
388
|
+
@async_thread&.kill
|
|
389
|
+
@async_thread = nil
|
|
390
|
+
end
|
|
329
391
|
end
|
|
330
392
|
end
|
data/lib/magick/rails/railtie.rb
CHANGED
|
@@ -25,7 +25,13 @@ if defined?(Rails)
|
|
|
25
25
|
if defined?(Redis)
|
|
26
26
|
begin
|
|
27
27
|
redis_url = app.config.respond_to?(:redis_url) ? app.config.redis_url : nil
|
|
28
|
-
|
|
28
|
+
# Use database 1 for Magick by default to avoid conflicts with Rails cache (which uses DB 0)
|
|
29
|
+
# Users can override this in their config/initializers/magick.rb
|
|
30
|
+
redis_db = 1
|
|
31
|
+
redis_options = {}
|
|
32
|
+
redis_options[:url] = redis_url if redis_url
|
|
33
|
+
redis_options[:db] = redis_db
|
|
34
|
+
redis_client = redis_url ? ::Redis.new(redis_options) : ::Redis.new(db: redis_db)
|
|
29
35
|
memory_adapter = Adapters::Memory.new
|
|
30
36
|
redis_adapter = Adapters::Redis.new(redis_client)
|
|
31
37
|
magick.adapter_registry = Adapters::Registry.new(memory_adapter, redis_adapter)
|
data/lib/magick/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: magick-feature-flags
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.9.
|
|
4
|
+
version: 0.9.24
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Lobanov
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-
|
|
11
|
+
date: 2025-12-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|