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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42699b3af1d542ac14b10f2b82ea4156da27b68e3e2a07e715cd528f0e3f936b
4
- data.tar.gz: 0c5235c6cb220201ed0443d1a4633009325d410620d8d28ecbb836cac01bdc25
3
+ metadata.gz: 9aaecf68655a77ae48f4f7b2d7f760528a4aa14aa5f37b5f8a603897e3040d40
4
+ data.tar.gz: 8b9694162f06fded4597aaadd14334c515f996979161915004ef113a979aba38
5
5
  SHA512:
6
- metadata.gz: 10fb678c4aeaf5a17f721341e01ec937bf9a14042e4a90fa0bcc1aba13c5e91272aba144fa05913a020660ffd602ccf7481d216e92364338c7e4a111794e0896
7
- data.tar.gz: '079e2d11d6ab0339929f965c1eb05fc934ea88522f26e268df324c366ad28268f87131233fe0f2315a5494328ae5fa18790e8e512fa550372852c3c1d61c3faf'
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**: Optimized for speed with memory-first caching strategy
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
- redis url: ENV['REDIS_URL']
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
- redis url: ENV['REDIS_URL'], namespace: 'magick:features'
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
- **Note:** When `redis_tracking: true` is set, usage counts are persisted to Redis and aggregated across all processes, giving you total usage statistics.
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
- redis_adapter = configure_redis_adapter(url: url, namespace: namespace, **options)
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
- ::Redis.new(url: url)
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
- ::Redis.new
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: minimize work in mutex
50
- feature_name_str = feature_name.to_s
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, operation, duration, success: success)
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(feature_name, operation: operation, duration: duration, success: success)
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 (avoid expensive checks)
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
- nil # Don't return metric object to avoid allocation overhead
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
@@ -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
- redis_client = redis_url ? ::Redis.new(url: redis_url) : ::Redis.new
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '0.9.22'
4
+ VERSION = '0.9.24'
5
5
  end
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.22
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-24 00:00:00.000000000 Z
11
+ date: 2025-12-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec