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 +4 -4
- data/README.md +114 -6
- data/app/controllers/magick/adminui/features_controller.rb +56 -0
- data/lib/magick/adapters/active_record.rb +24 -29
- data/lib/magick/adapters/memory.rb +11 -5
- data/lib/magick/adapters/redis.rb +37 -15
- data/lib/magick/adapters/registry.rb +16 -3
- data/lib/magick/dsl.rb +21 -0
- data/lib/magick/export_import.rb +1 -1
- data/lib/magick/feature.rb +124 -10
- data/lib/magick/targeting/complex.rb +2 -4
- data/lib/magick/targeting/custom_attribute.rb +4 -0
- data/lib/magick/targeting/date_range.rb +2 -0
- data/lib/magick/targeting/ip_address.rb +1 -1
- data/lib/magick/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f87e02cd09ce5fb4e837f66c5117d2ca709918cd4bcc1f1fe808ceaafe7e049
|
|
4
|
+
data.tar.gz: 5ed85473443dd9e065c2a7298030a361b1fe965376c68085b0355d57124c04e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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. **
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
69
|
+
keys.each_with_index do |key, idx|
|
|
65
70
|
feature_name = key.sub("#{namespace}:", '')
|
|
66
|
-
raw =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
data/lib/magick/export_import.rb
CHANGED
|
@@ -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:
|
|
78
|
+
Magick::Rails::Events.imported(format: :json, feature_count: features.length)
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
features
|
data/lib/magick/feature.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
908
|
+
conditions.all?(&evaluate_condition)
|
|
849
909
|
when :or, :any
|
|
850
|
-
|
|
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
|
-
|
|
967
|
-
|
|
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
|
-
|
|
16
|
+
@conditions.all? { |condition| condition.matches?(context) }
|
|
19
17
|
when :or, :any
|
|
20
|
-
|
|
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)
|
data/lib/magick/version.rb
CHANGED