magick-feature-flags 1.1.2 → 1.2.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: 7ce19a872aa305d47f9bd4fa75023829cb2a63aafa86809260049cd09e11e696
4
- data.tar.gz: 8a1bed262b71e443384ba60505a2af92ae5f4a1399112730887ec89f6379097a
3
+ metadata.gz: 7f87e02cd09ce5fb4e837f66c5117d2ca709918cd4bcc1f1fe808ceaafe7e049
4
+ data.tar.gz: 5ed85473443dd9e065c2a7298030a361b1fe965376c68085b0355d57124c04e2
5
5
  SHA512:
6
- metadata.gz: 441203a6279d12d0ef44043e2145fc591e74cfe849ba186f2c5f6baebc617f78af6a2a6499cfc2c12e24fc0832640ccfd044436a3030bfbbf8d2549f9ff616bb
7
- data.tar.gz: 4370e495deae46281c40f68d81ca1f388fd962297104c6409af7d91a21d6af7eafa5632ec3535c0dc4bcd570b428dd6286b0135a4b2f0c128a2c5e40b8248021
6
+ metadata.gz: 29402326b70df322e48ae798571ee500a12eafc964545ed0e1a2f15e56bce6197254084b3c9eb75cadd22a61e2583c74fbbb41d0c50f478f733e561d8c41917f
7
+ data.tar.gz: a8d84d507bab0fd1e02eb3583830c059f3cdd5d475a137bfe952a85194fc66f2e5a8f8b548096722664ae0a92325087d265b17aa3f1d12e6d3f3f5176c212ca5
data/README.md CHANGED
@@ -6,6 +6,7 @@ A performant and memory-efficient feature toggle gem for Ruby and Rails applicat
6
6
 
7
7
  - **Multiple Feature Types**: Boolean, string, and number feature flags
8
8
  - **Flexible Targeting**: Enable features for specific users, groups, roles, tags, or percentages
9
+ - **Exclusions**: Exclude specific users, groups, roles, tags, or IPs — exclusions always take priority over inclusions
9
10
  - **Dual Backend**: Memory adapter (fast) with Redis fallback (persistent)
10
11
  - **Rails Integration**: Seamless integration with Rails, including request store caching
11
12
  - **DSL Support**: Define features in a Ruby DSL file (`config/features.rb`)
@@ -193,6 +194,76 @@ feature.enable_for_ip_addresses('192.168.1.0/24', '10.0.0.1')
193
194
  feature.enable_for_custom_attribute(:subscription_tier, ['premium', 'enterprise'])
194
195
  ```
195
196
 
197
+ ### Feature Exclusions
198
+
199
+ Exclusions let you block specific users, groups, roles, tags, or IP addresses from a feature — even if they match an inclusion rule. **Exclusions always take priority over inclusions.**
200
+
201
+ ```ruby
202
+ feature = Magick[:new_dashboard]
203
+
204
+ # Exclude specific users
205
+ feature.exclude_user(456)
206
+ feature.exclude_user(789)
207
+
208
+ # Exclude specific tags
209
+ feature.exclude_tag('legacy_tier')
210
+ feature.exclude_tag('banned')
211
+
212
+ # Exclude specific groups
213
+ feature.exclude_group('suspended_users')
214
+
215
+ # Exclude specific roles
216
+ feature.exclude_role('guest')
217
+
218
+ # Exclude IP addresses (supports CIDR notation)
219
+ feature.exclude_ip_addresses(['10.0.0.0/8', '192.168.1.100'])
220
+
221
+ # Remove exclusions
222
+ feature.remove_user_exclusion(456)
223
+ feature.remove_tag_exclusion('legacy_tier')
224
+ feature.remove_group_exclusion('suspended_users')
225
+ feature.remove_role_exclusion('guest')
226
+ feature.remove_ip_exclusion # Removes all IP exclusions
227
+ ```
228
+
229
+ **Exclusions win over inclusions:**
230
+
231
+ ```ruby
232
+ feature = Magick[:premium_features]
233
+ feature.enable # Enabled globally for everyone
234
+
235
+ feature.exclude_user(123)
236
+ Magick.enabled?(:premium_features, user_id: 123) # => false (excluded)
237
+ Magick.enabled?(:premium_features, user_id: 456) # => true (not excluded)
238
+
239
+ # Even percentage targeting is overridden
240
+ feature.enable_percentage_of_users(100) # 100% of users
241
+ feature.exclude_user(123)
242
+ Magick.enabled?(:premium_features, user_id: 123) # => false (still excluded)
243
+ ```
244
+
245
+ **Exclusions in DSL (`config/features.rb`):**
246
+
247
+ ```ruby
248
+ boolean_feature :new_dashboard, default: true
249
+
250
+ # Exclude problematic users
251
+ exclude_user :new_dashboard, 'user_123'
252
+ exclude_user :new_dashboard, 'user_456'
253
+
254
+ # Exclude legacy tiers
255
+ exclude_tag :new_dashboard, 'legacy_tier'
256
+
257
+ # Exclude groups
258
+ exclude_group :new_dashboard, 'banned_users'
259
+
260
+ # Exclude roles
261
+ exclude_role :new_dashboard, 'suspended'
262
+
263
+ # Exclude IPs
264
+ exclude_ip_addresses :new_dashboard, '10.0.0.0/8'
265
+ ```
266
+
196
267
  ### Checking Feature Enablement with Objects
197
268
 
198
269
  You can check if a feature is enabled for an object (like a User model) and its fields:
@@ -258,6 +329,12 @@ result = feature.enable_for_role('admin') # => true
258
329
  result = feature.enable_for_tag('premium') # => true
259
330
  result = feature.enable_percentage_of_users(25) # => true
260
331
  result = feature.set_value(true) # => true
332
+
333
+ # Exclusion methods also return true
334
+ result = feature.exclude_user(456) # => true
335
+ result = feature.exclude_tag('banned') # => true
336
+ result = feature.exclude_group('blocked') # => true
337
+ result = feature.exclude_role('guest') # => true
261
338
  ```
262
339
 
263
340
  ### DSL Configuration
@@ -303,6 +380,13 @@ boolean_feature :premium_feature,
303
380
 
304
381
  # Add dependencies after feature definition
305
382
  add_dependency(:another_feature, :required_feature)
383
+
384
+ # Exclusions - block specific users/groups/roles/tags
385
+ exclude_user :new_dashboard, 'user_123'
386
+ exclude_tag :new_dashboard, 'legacy_tier'
387
+ exclude_group :new_dashboard, 'banned_users'
388
+ exclude_role :new_dashboard, 'suspended'
389
+ exclude_ip_addresses :new_dashboard, '10.0.0.0/8'
306
390
  ```
307
391
 
308
392
  ### In Controllers
@@ -443,8 +527,8 @@ end
443
527
 
444
528
  Magick uses a dual-adapter strategy:
445
529
 
446
- 1. **Memory Adapter**: Fast, in-memory storage with TTL support
447
- 2. **Redis Adapter**: Persistent storage for distributed systems (optional)
530
+ 1. **Memory Adapter**: Fast, in-memory storage with TTL support and JSON serialization
531
+ 2. **Redis Adapter**: Persistent storage for distributed systems (optional), uses SCAN instead of KEYS and pipelined bulk operations
448
532
 
449
533
  The registry automatically falls back from memory to Redis if a feature isn't found in memory. When features are updated:
450
534
  - Both adapters are updated simultaneously
@@ -589,6 +673,7 @@ Once mounted, visit `/magick` in your browser to access the Admin UI.
589
673
  - **Role Targeting**: Select roles from a configured list (checkboxes)
590
674
  - **Tag Targeting**: Select tags from a dynamically loaded list (checkboxes)
591
675
  - **User Targeting**: Enter user IDs (comma-separated)
676
+ - **Exclusions**: Exclude users, roles, and tags from a feature (exclusions override inclusions)
592
677
  - **Visual Display**: See all active targeting rules with badges
593
678
  - **Edit Features**: Update feature values (boolean, string, number) directly from the UI
594
679
  - **Statistics**: View performance metrics and usage statistics for each feature
@@ -641,7 +726,12 @@ The Admin UI provides a comprehensive targeting interface:
641
726
  - Add or remove users dynamically
642
727
  - Clear all user targeting by leaving the field empty
643
728
 
644
- 4. **Visual Feedback**:
729
+ 4. **Exclusion Targeting**:
730
+ - Exclude specific users (comma-separated IDs), roles, and tags
731
+ - Exclusions always take priority over inclusions
732
+ - Managed through the same targeting form
733
+
734
+ 5. **Visual Feedback**:
645
735
  - All targeting rules are displayed as badges in the feature details view
646
736
  - Easy to see which roles/tags/users have access to each feature
647
737
 
@@ -663,7 +753,27 @@ The Admin UI provides the following routes:
663
753
 
664
754
  **Security:**
665
755
 
666
- The Admin UI is a basic Rails Engine without built-in authentication. **You should add authentication/authorization** before mounting it in production. For example:
756
+ The Admin UI includes a built-in authentication hook via `require_role`. Configure it to gate access:
757
+
758
+ ```ruby
759
+ # config/initializers/magick.rb
760
+ Rails.application.config.after_initialize do
761
+ Magick::AdminUI.configure do |config|
762
+ # Option 1: Lambda-based authentication (recommended)
763
+ config.require_role = ->(controller) {
764
+ # Return true to allow, false to deny (returns 403 Forbidden)
765
+ controller.current_user&.admin?
766
+ }
767
+
768
+ # Option 2: Check for a specific role
769
+ config.require_role = ->(controller) {
770
+ controller.current_user&.role == 'admin'
771
+ }
772
+ end
773
+ end
774
+ ```
775
+
776
+ You can also gate access at the routing level:
667
777
 
668
778
  ```ruby
669
779
  # config/routes.rb
@@ -680,8 +790,6 @@ Rails.application.routes.draw do
680
790
  end
681
791
  ```
682
792
 
683
- Or use a before_action in your ApplicationController if you mount it at the application level.
684
-
685
793
  **Note:** The Admin UI is optional and only loaded when explicitly enabled in configuration. It requires Rails to be available.
686
794
 
687
795
  ### Feature Types
@@ -6,6 +6,7 @@ module Magick
6
6
  # Include route helpers so views can use magick_admin_ui.* helpers
7
7
  include Magick::AdminUI::Engine.routes.url_helpers
8
8
  layout 'application'
9
+ before_action :authenticate_admin!
9
10
  before_action :set_feature, only: %i[show edit update enable disable enable_for_user enable_for_role disable_for_role update_targeting]
10
11
 
11
12
  # Make route helpers available in views via magick_admin_ui helper
@@ -207,6 +208,49 @@ module Magick
207
208
  @feature.disable_percentage_of_requests if current_targeting[:percentage_requests]
208
209
  end
209
210
 
211
+ # Handle excluded user IDs
212
+ if targeting_params[:excluded_user_ids].present?
213
+ excluded_user_ids = targeting_params[:excluded_user_ids].split(',').map(&:strip).reject(&:blank?)
214
+ current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
215
+
216
+ (current_excluded_users - excluded_user_ids).each do |user_id|
217
+ @feature.remove_user_exclusion(user_id) if user_id.present?
218
+ end
219
+
220
+ (excluded_user_ids - current_excluded_users).each do |user_id|
221
+ @feature.exclude_user(user_id) if user_id.present?
222
+ end
223
+ elsif targeting_params.key?(:excluded_user_ids) && targeting_params[:excluded_user_ids].blank?
224
+ current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
225
+ current_excluded_users.each do |user_id|
226
+ @feature.remove_user_exclusion(user_id) if user_id.present?
227
+ end
228
+ end
229
+
230
+ # Handle excluded roles
231
+ current_excluded_roles = current_targeting[:excluded_roles].is_a?(Array) ? current_targeting[:excluded_roles] : (current_targeting[:excluded_roles] ? [current_targeting[:excluded_roles]] : [])
232
+ selected_excluded_roles = Array(targeting_params[:excluded_roles]).reject(&:blank?)
233
+
234
+ (current_excluded_roles - selected_excluded_roles).each do |role|
235
+ @feature.remove_role_exclusion(role) if role.present?
236
+ end
237
+
238
+ (selected_excluded_roles - current_excluded_roles).each do |role|
239
+ @feature.exclude_role(role) if role.present?
240
+ end
241
+
242
+ # Handle excluded tags
243
+ current_excluded_tags = current_targeting[:excluded_tags].is_a?(Array) ? current_targeting[:excluded_tags] : (current_targeting[:excluded_tags] ? [current_targeting[:excluded_tags]] : [])
244
+ selected_excluded_tags = Array(targeting_params[:excluded_tags]).reject(&:blank?)
245
+
246
+ (current_excluded_tags - selected_excluded_tags).each do |tag|
247
+ @feature.remove_tag_exclusion(tag) if tag.present?
248
+ end
249
+
250
+ (selected_excluded_tags - current_excluded_tags).each do |tag|
251
+ @feature.exclude_tag(tag) if tag.present?
252
+ end
253
+
210
254
  # After all targeting updates, ensure we're using the registered feature instance
211
255
  # and reload it to get the latest state from adapter
212
256
  feature_name = @feature.name.to_s
@@ -225,6 +269,18 @@ module Magick
225
269
 
226
270
  private
227
271
 
272
+ def authenticate_admin!
273
+ return unless Magick::AdminUI.config.require_role
274
+
275
+ auth_callback = Magick::AdminUI.config.require_role
276
+ if auth_callback.respond_to?(:call)
277
+ unless auth_callback.call(self)
278
+ head :forbidden
279
+ nil
280
+ end
281
+ end
282
+ end
283
+
228
284
  def set_feature
229
285
  feature_name = params[:id].to_s
230
286
  @feature = Magick.features[feature_name] || Magick[feature_name]
@@ -5,6 +5,10 @@ module Magick
5
5
  class ActiveRecord < Base
6
6
  def initialize(model_class: nil)
7
7
  @model_class = model_class || default_model_class
8
+ # Cache AR version check once at init time (hot path optimization)
9
+ ar_major = ::ActiveRecord::VERSION::MAJOR
10
+ ar_minor = ::ActiveRecord::VERSION::MINOR
11
+ @use_json = ar_major >= 8 || (ar_major == 7 && ar_minor >= 1)
8
12
  # Verify table exists - raise clear error if it doesn't
9
13
  unless @model_class.table_exists?
10
14
  raise AdapterError, "Table 'magick_features' does not exist. Please run: rails generate magick:active_record && rails db:migrate"
@@ -30,20 +34,18 @@ module Magick
30
34
  feature_name_str = feature_name.to_s
31
35
  retries = 5
32
36
  begin
33
- record = @model_class.find_or_initialize_by(feature_name: feature_name_str)
34
- # Ensure data is a Hash (works for both serialize and attribute :json)
35
- data = record.data || {}
36
- data = {} unless data.is_a?(Hash)
37
- data[key.to_s] = serialize_value(value)
38
- record.data = data
39
- # Use Time.now if Time.current is not available (for non-Rails environments)
40
- record.updated_at = defined?(Time.current) ? Time.current : Time.now
41
- record.save!
37
+ @model_class.transaction do
38
+ record = @model_class.lock.find_or_create_by!(feature_name: feature_name_str)
39
+ data = record.data || {}
40
+ data = {} unless data.is_a?(Hash)
41
+ data[key.to_s] = serialize_value(value)
42
+ record.update!(data: data, updated_at: defined?(Time.current) ? Time.current : Time.now)
43
+ end
42
44
  rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
43
- # SQLite busy/locked errors - retry with exponential backoff
45
+ # SQLite busy/locked errors - retry with linear backoff
44
46
  if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout')) && retries > 0
45
47
  retries -= 1
46
- sleep(0.01 * (6 - retries)) # Exponential backoff: 0.01, 0.02, 0.03, 0.04, 0.05
48
+ sleep(0.01 * (6 - retries))
47
49
  retry
48
50
  end
49
51
  raise AdapterError, "Failed to set in ActiveRecord: #{e.message}"
@@ -97,9 +99,8 @@ module Magick
97
99
  end
98
100
 
99
101
  def load_all_features_data
100
- records = @model_class.all
101
102
  result = {}
102
- records.each do |record|
103
+ @model_class.find_each do |record|
103
104
  data = record.data || {}
104
105
  next unless data.is_a?(Hash)
105
106
 
@@ -118,15 +119,15 @@ module Magick
118
119
  feature_name_str = feature_name.to_s
119
120
  retries = 5
120
121
  begin
121
- record = @model_class.find_or_initialize_by(feature_name: feature_name_str)
122
- existing_data = record.data || {}
123
- existing_data = {} unless existing_data.is_a?(Hash)
124
- data_hash.each do |key, value|
125
- existing_data[key.to_s] = serialize_value(value)
122
+ @model_class.transaction do
123
+ record = @model_class.lock.find_or_create_by!(feature_name: feature_name_str)
124
+ existing_data = record.data || {}
125
+ existing_data = {} unless existing_data.is_a?(Hash)
126
+ data_hash.each do |key, value|
127
+ existing_data[key.to_s] = serialize_value(value)
128
+ end
129
+ record.update!(data: existing_data, updated_at: defined?(Time.current) ? Time.current : Time.now)
126
130
  end
127
- record.data = existing_data
128
- record.updated_at = defined?(Time.current) ? Time.current : Time.now
129
- record.save!
130
131
  rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
131
132
  if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout')) && retries > 0
132
133
  retries -= 1
@@ -176,19 +177,13 @@ module Magick
176
177
  end
177
178
 
178
179
  def serialize_value(value)
179
- # For ActiveRecord 8.1+ with attribute :json, we can store booleans as-is
180
- # For older versions with serialize, we convert to strings
181
- ar_major = ::ActiveRecord::VERSION::MAJOR
182
- ar_minor = ::ActiveRecord::VERSION::MINOR
183
- use_json = ar_major >= 8 || (ar_major == 7 && ar_minor >= 1)
184
-
185
180
  case value
186
181
  when Hash, Array
187
182
  value
188
183
  when true
189
- use_json ? true : 'true'
184
+ @use_json ? true : 'true'
190
185
  when false
191
- use_json ? false : 'false'
186
+ @use_json ? false : 'false'
192
187
  else
193
188
  value
194
189
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Magick
4
6
  module Adapters
5
7
  class Memory < Base
@@ -134,7 +136,7 @@ module Magick
134
136
  def serialize_value(value)
135
137
  case value
136
138
  when Hash, Array
137
- Marshal.dump(value)
139
+ JSON.generate(value)
138
140
  else
139
141
  value
140
142
  end
@@ -145,10 +147,14 @@ module Magick
145
147
 
146
148
  case value
147
149
  when String
148
- # Try to unmarshal if it's a serialized hash/array
149
- begin
150
- Marshal.load(value)
151
- rescue StandardError
150
+ # Only attempt JSON parse on strings that look like JSON objects/arrays
151
+ if value.start_with?('{', '[')
152
+ begin
153
+ JSON.parse(value)
154
+ rescue JSON::ParserError
155
+ value
156
+ end
157
+ else
152
158
  value
153
159
  end
154
160
  else
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Magick
4
6
  module Adapters
5
7
  class Redis < Base
@@ -37,8 +39,7 @@ module Magick
37
39
  end
38
40
 
39
41
  def all_features
40
- pattern = "#{namespace}:*"
41
- keys = redis.keys(pattern)
42
+ keys = scan_keys
42
43
  keys.map { |key| key.sub("#{namespace}:", '') }
43
44
  rescue StandardError => e
44
45
  raise AdapterError, "Failed to get all features from Redis: #{e.message}"
@@ -56,14 +57,18 @@ module Magick
56
57
  end
57
58
 
58
59
  def load_all_features_data
59
- pattern = "#{namespace}:*"
60
- keys = redis.keys(pattern)
60
+ keys = scan_keys
61
61
  return {} if keys.empty?
62
62
 
63
+ # Pipeline all HGETALL calls to avoid N+1 round-trips
64
+ raw_results = redis.pipelined do |pipeline|
65
+ keys.each { |key| pipeline.hgetall(key) }
66
+ end
67
+
63
68
  result = {}
64
- keys.each do |key|
69
+ keys.each_with_index do |key, idx|
65
70
  feature_name = key.sub("#{namespace}:", '')
66
- raw = redis.hgetall(key)
71
+ raw = raw_results[idx]
67
72
  next if raw.nil? || raw.empty?
68
73
 
69
74
  feature_data = {}
@@ -89,10 +94,28 @@ module Magick
89
94
  raise AdapterError, "Failed to set all data in Redis: #{e.message}"
90
95
  end
91
96
 
97
+ # Public accessor for the underlying Redis client
98
+ def client
99
+ @redis
100
+ end
101
+
92
102
  private
93
103
 
94
104
  attr_reader :redis, :namespace
95
105
 
106
+ # Use SCAN instead of KEYS to avoid blocking Redis
107
+ def scan_keys
108
+ pattern = "#{namespace}:*"
109
+ keys = []
110
+ cursor = '0'
111
+ loop do
112
+ cursor, batch = redis.scan(cursor, match: pattern, count: 100)
113
+ keys.concat(batch)
114
+ break if cursor == '0'
115
+ end
116
+ keys
117
+ end
118
+
96
119
  def key_for(feature_name)
97
120
  "#{namespace}:#{feature_name}"
98
121
  end
@@ -109,7 +132,7 @@ module Magick
109
132
  def serialize_value(value)
110
133
  case value
111
134
  when Hash, Array
112
- Marshal.dump(value)
135
+ JSON.generate(value)
113
136
  when true
114
137
  'true'
115
138
  when false
@@ -122,17 +145,16 @@ module Magick
122
145
  def deserialize_value(value)
123
146
  return nil if value.nil?
124
147
 
125
- # Try to unmarshal if it's a serialized hash/array
126
- if value.is_a?(String) && value.start_with?("\x04\x08")
127
- begin
128
- Marshal.load(value)
129
- rescue StandardError
130
- value
131
- end
132
- elsif value == 'true'
148
+ if value == 'true'
133
149
  true
134
150
  elsif value == 'false'
135
151
  false
152
+ elsif value.is_a?(String) && value.start_with?('{', '[')
153
+ begin
154
+ JSON.parse(value)
155
+ rescue JSON::ParserError
156
+ value
157
+ end
136
158
  else
137
159
  value
138
160
  end
@@ -266,7 +266,7 @@ module Magick
266
266
  def redis_client
267
267
  return nil unless redis_adapter
268
268
 
269
- redis_adapter.instance_variable_get(:@redis)
269
+ redis_adapter.client
270
270
  end
271
271
 
272
272
  # Publish cache invalidation message to Redis Pub/Sub (without deleting local memory cache)
@@ -276,7 +276,7 @@ module Magick
276
276
  return unless redis_adapter
277
277
 
278
278
  begin
279
- redis_client = redis_adapter.instance_variable_get(:@redis)
279
+ redis_client = redis_adapter.client
280
280
  redis_client&.publish(CACHE_INVALIDATION_CHANNEL, feature_name.to_s)
281
281
  rescue StandardError => e
282
282
  # Silently fail - cache invalidation is best effort
@@ -299,6 +299,9 @@ module Magick
299
299
  # Check if a feature was recently written by this process
300
300
  def local_write?(feature_name_str)
301
301
  @reload_mutex.synchronize do
302
+ # Periodic cleanup of stale entries to prevent unbounded growth
303
+ cleanup_stale_tracking_entries
304
+
302
305
  wrote_at = @local_writes[feature_name_str]
303
306
  return false unless wrote_at
304
307
 
@@ -311,6 +314,16 @@ module Magick
311
314
  end
312
315
  end
313
316
 
317
+ # Clean up stale entries from tracking hashes (called under mutex)
318
+ def cleanup_stale_tracking_entries
319
+ now = Time.now.to_f
320
+ return if @last_tracking_cleanup && (now - @last_tracking_cleanup) < 60.0
321
+
322
+ @last_tracking_cleanup = now
323
+ @local_writes.delete_if { |_, wrote_at| (now - wrote_at) >= LOCAL_WRITE_TTL }
324
+ @last_reload_times.delete_if { |_, reload_at| (now - reload_at) >= 10.0 }
325
+ end
326
+
314
327
  # Start a background thread to listen for cache invalidation messages
315
328
  def start_cache_invalidation_subscriber
316
329
  return unless redis_adapter && defined?(Thread)
@@ -320,7 +333,7 @@ module Magick
320
333
  return if defined?(Rails) && Rails.env.test?
321
334
 
322
335
  @subscriber_thread = Thread.new do
323
- redis_client = redis_adapter.instance_variable_get(:@redis)
336
+ redis_client = redis_adapter.client
324
337
  return unless redis_client
325
338
 
326
339
  begin
data/lib/magick/dsl.rb CHANGED
@@ -58,6 +58,27 @@ module Magick
58
58
  Magick[feature_name].enable_for_custom_attribute(attribute_name, values, operator: operator)
59
59
  end
60
60
 
61
+ # Exclusion DSL methods
62
+ def exclude_user(feature_name, user_id)
63
+ Magick[feature_name].exclude_user(user_id)
64
+ end
65
+
66
+ def exclude_tag(feature_name, tag_name)
67
+ Magick[feature_name].exclude_tag(tag_name)
68
+ end
69
+
70
+ def exclude_group(feature_name, group_name)
71
+ Magick[feature_name].exclude_group(group_name)
72
+ end
73
+
74
+ def exclude_role(feature_name, role_name)
75
+ Magick[feature_name].exclude_role(role_name)
76
+ end
77
+
78
+ def exclude_ip_addresses(feature_name, *ip_addresses)
79
+ Magick[feature_name].exclude_ip_addresses(ip_addresses)
80
+ end
81
+
61
82
  def set_variants(feature_name, variants)
62
83
  Magick[feature_name].set_variants(variants)
63
84
  end
@@ -75,7 +75,7 @@ module Magick
75
75
 
76
76
  # Rails 8+ event
77
77
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
78
- Magick::Rails::Events.imported(format: format, feature_count: features.length)
78
+ Magick::Rails::Events.imported(format: :json, feature_count: features.length)
79
79
  end
80
80
 
81
81
  features
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'time'
4
5
  require_relative '../magick/feature_variant'
5
6
 
6
7
  module Magick
@@ -89,16 +90,17 @@ module Magick
89
90
  end
90
91
 
91
92
  def check_enabled(context = {})
93
+ # Dup context to avoid mutating the caller's hash
94
+ context = context.dup
95
+
92
96
  # Extract context from user object if provided
93
97
  # This allows Magick.enabled?(:feature, user: player) to work
94
98
  if context[:user]
95
- extracted = extract_context_from_object(context[:user])
99
+ extracted = extract_context_from_object(context.delete(:user))
96
100
  # Merge extracted context, but don't override explicit values already in context
97
101
  extracted.each do |key, value|
98
102
  context[key] = value unless context.key?(key)
99
103
  end
100
- # Remove :user key after extraction to avoid confusion
101
- context.delete(:user)
102
104
  end
103
105
 
104
106
  # Fast path: check status first
@@ -107,6 +109,9 @@ module Magick
107
109
 
108
110
  # Fast path: skip targeting checks if targeting is empty (most common case)
109
111
  unless @_targeting_empty
112
+ # Check exclusions FIRST — exclusions always take priority over inclusions
113
+ return false if excluded?(context)
114
+
110
115
  # Check date/time range targeting
111
116
  return false if targeting[:date_range] && !date_range_active?(targeting[:date_range])
112
117
 
@@ -262,6 +267,58 @@ module Magick
262
267
  true
263
268
  end
264
269
 
270
+ # --- Exclusion methods ---
271
+
272
+ def exclude_user(user_id)
273
+ enable_targeting(:excluded_users, user_id)
274
+ true
275
+ end
276
+
277
+ def remove_user_exclusion(user_id)
278
+ disable_targeting(:excluded_users, user_id)
279
+ true
280
+ end
281
+
282
+ def exclude_tag(tag_name)
283
+ enable_targeting(:excluded_tags, tag_name)
284
+ true
285
+ end
286
+
287
+ def remove_tag_exclusion(tag_name)
288
+ disable_targeting(:excluded_tags, tag_name)
289
+ true
290
+ end
291
+
292
+ def exclude_group(group_name)
293
+ enable_targeting(:excluded_groups, group_name)
294
+ true
295
+ end
296
+
297
+ def remove_group_exclusion(group_name)
298
+ disable_targeting(:excluded_groups, group_name)
299
+ true
300
+ end
301
+
302
+ def exclude_role(role_name)
303
+ enable_targeting(:excluded_roles, role_name)
304
+ true
305
+ end
306
+
307
+ def remove_role_exclusion(role_name)
308
+ disable_targeting(:excluded_roles, role_name)
309
+ true
310
+ end
311
+
312
+ def exclude_ip_addresses(ip_addresses)
313
+ enable_targeting(:excluded_ip_addresses, Array(ip_addresses))
314
+ true
315
+ end
316
+
317
+ def remove_ip_exclusion
318
+ disable_targeting(:excluded_ip_addresses)
319
+ true
320
+ end
321
+
265
322
  def enable_percentage_of_users(percentage)
266
323
  @targeting[:percentage_users] = percentage.to_f
267
324
  save_targeting
@@ -717,6 +774,10 @@ module Magick
717
774
  # Normalize targeting keys (handle both string and symbol keys)
718
775
  target = targeting.transform_keys(&:to_sym)
719
776
 
777
+ # If only exclusion keys exist (no inclusion targeting), treat as globally enabled
778
+ inclusion_keys = target.keys.reject { |k| k.to_s.start_with?('excluded_') }
779
+ return true if inclusion_keys.empty?
780
+
720
781
  # Check user targeting
721
782
  if context[:user_id] && target[:user]
722
783
  user_list = target[:user].is_a?(Array) ? target[:user] : [target[:user]]
@@ -780,7 +841,7 @@ module Magick
780
841
  ip_list.any? do |ip_str|
781
842
  IPAddr.new(ip_str).include?(client_ip)
782
843
  end
783
- rescue IPAddr::InvalidAddressError
844
+ rescue IPAddr::InvalidAddressError, ArgumentError
784
845
  false
785
846
  end
786
847
 
@@ -819,8 +880,7 @@ module Magick
819
880
  conditions = complex_config[:conditions] || complex_config['conditions'] || []
820
881
  operator = (complex_config[:operator] || complex_config['operator'] || :and).to_sym
821
882
 
822
- results = conditions.map do |condition|
823
- # Each condition is a hash with type and params
883
+ evaluate_condition = lambda do |condition|
824
884
  condition_type = (condition[:type] || condition['type']).to_sym
825
885
  condition_params = condition[:params] || condition['params'] || {}
826
886
 
@@ -845,9 +905,9 @@ module Magick
845
905
 
846
906
  case operator
847
907
  when :and, :all
848
- results.all?
908
+ conditions.all?(&evaluate_condition)
849
909
  when :or, :any
850
- results.any?
910
+ conditions.any?(&evaluate_condition)
851
911
  else
852
912
  false
853
913
  end
@@ -962,9 +1022,63 @@ module Magick
962
1022
  dependent_features
963
1023
  end
964
1024
 
1025
+ def excluded?(context)
1026
+ target = targeting.transform_keys(&:to_sym)
1027
+
1028
+ # Check excluded users
1029
+ if context[:user_id] && target[:excluded_users]
1030
+ excluded_list = target[:excluded_users].is_a?(Array) ? target[:excluded_users] : [target[:excluded_users]]
1031
+ return true if excluded_list.include?(context[:user_id].to_s)
1032
+ end
1033
+
1034
+ # Check excluded groups
1035
+ if context[:group] && target[:excluded_groups]
1036
+ excluded_list = target[:excluded_groups].is_a?(Array) ? target[:excluded_groups] : [target[:excluded_groups]]
1037
+ return true if excluded_list.include?(context[:group].to_s)
1038
+ end
1039
+
1040
+ # Check excluded roles
1041
+ if context[:role] && target[:excluded_roles]
1042
+ excluded_list = target[:excluded_roles].is_a?(Array) ? target[:excluded_roles] : [target[:excluded_roles]]
1043
+ return true if excluded_list.include?(context[:role].to_s)
1044
+ end
1045
+
1046
+ # Check excluded tags
1047
+ if context[:tags] && target[:excluded_tags]
1048
+ context_tags = Array(context[:tags]).map(&:to_s)
1049
+ excluded_tags = target[:excluded_tags].is_a?(Array) ? target[:excluded_tags].map(&:to_s) : [target[:excluded_tags].to_s]
1050
+ return true if (context_tags & excluded_tags).any?
1051
+ end
1052
+
1053
+ # Check excluded IP addresses
1054
+ if context[:ip_address] && target[:excluded_ip_addresses]
1055
+ begin
1056
+ require 'ipaddr'
1057
+ excluded_ips = Array(target[:excluded_ip_addresses])
1058
+ client_ip = IPAddr.new(context[:ip_address])
1059
+ return true if excluded_ips.any? { |ip_str| IPAddr.new(ip_str).include?(client_ip) }
1060
+ rescue IPAddr::InvalidAddressError, ArgumentError
1061
+ # Invalid IP, not excluded
1062
+ end
1063
+ end
1064
+
1065
+ false
1066
+ end
1067
+
965
1068
  def enable_targeting(type, value)
966
- @targeting[type] ||= []
967
- @targeting[type] << value.to_s unless @targeting[type].include?(value.to_s)
1069
+ case type
1070
+ when :date_range, :custom_attributes, :complex_conditions, :variants, :excluded_ip_addresses
1071
+ # These types store structured data directly (Hash or Array of Hashes)
1072
+ @targeting[type] = value
1073
+ when :percentage_users, :percentage_requests
1074
+ # Numeric types store a single value
1075
+ @targeting[type] = value.to_f
1076
+ else
1077
+ # Array-based types (user, group, role, tag, ip_address)
1078
+ @targeting[type] ||= []
1079
+ str_value = value.to_s
1080
+ @targeting[type] << str_value unless @targeting[type].include?(str_value)
1081
+ end
968
1082
  save_targeting
969
1083
 
970
1084
  # Rails 8+ event
@@ -11,13 +11,11 @@ module Magick
11
11
  def matches?(context)
12
12
  return false if @conditions.empty?
13
13
 
14
- results = @conditions.map { |condition| condition.matches?(context) }
15
-
16
14
  case @operator
17
15
  when :and, :all
18
- results.all?
16
+ @conditions.all? { |condition| condition.matches?(context) }
19
17
  when :or, :any
20
- results.any?
18
+ @conditions.any? { |condition| condition.matches?(context) }
21
19
  else
22
20
  false
23
21
  end
@@ -7,6 +7,10 @@ module Magick
7
7
  @attribute_name = attribute_name.to_sym
8
8
  @values = Array(values)
9
9
  @operator = operator.to_sym
10
+
11
+ if %i[greater_than gt less_than lt].include?(@operator) && @values.empty?
12
+ raise ArgumentError, "#{@operator} operator requires at least one value"
13
+ end
10
14
  end
11
15
 
12
16
  def matches?(context)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module Magick
4
6
  module Targeting
5
7
  class DateRange < Base
@@ -14,7 +14,7 @@ module Magick
14
14
 
15
15
  client_ip = IPAddr.new(context[:ip_address])
16
16
  @ip_addresses.any? { |ip| ip.include?(client_ip) }
17
- rescue IPAddr::InvalidAddressError
17
+ rescue IPAddr::InvalidAddressError, ArgumentError
18
18
  false
19
19
  end
20
20
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '1.1.2'
4
+ VERSION = '1.2.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov