magick-feature-flags 1.1.2 → 1.1.3
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/app/controllers/magick/adminui/features_controller.rb +13 -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/export_import.rb +1 -1
- data/lib/magick/feature.rb +22 -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: e896cf3753f151fb2f1082a231c9af82095e3caea7716642a9f535e60d36e9e4
|
|
4
|
+
data.tar.gz: 24992a605474319bd831fbd60a6d955ba05de266d7419f60065fef2a2d32d2d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba376fb1fea1613bd7841fd4a56b8ed30833ab91c6563fad61abde4127caba802330b9bf14b9b0a52094addd7c58b92cb85d30af2b78c283647121de69fee19c
|
|
7
|
+
data.tar.gz: cfe7ad50afee3b0c7b192a1bb8a5b035834672f21d29627f67a3eda2939330880806686c655ccc6d17d4875c7d6fd029fc1f1732ee68562cc520373a2d54190f
|
|
@@ -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
|
|
@@ -225,6 +226,18 @@ module Magick
|
|
|
225
226
|
|
|
226
227
|
private
|
|
227
228
|
|
|
229
|
+
def authenticate_admin!
|
|
230
|
+
return unless Magick::AdminUI.config.require_role
|
|
231
|
+
|
|
232
|
+
auth_callback = Magick::AdminUI.config.require_role
|
|
233
|
+
if auth_callback.respond_to?(:call)
|
|
234
|
+
unless auth_callback.call(self)
|
|
235
|
+
head :forbidden
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
228
241
|
def set_feature
|
|
229
242
|
feature_name = params[:id].to_s
|
|
230
243
|
@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/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
|
|
@@ -780,7 +782,7 @@ module Magick
|
|
|
780
782
|
ip_list.any? do |ip_str|
|
|
781
783
|
IPAddr.new(ip_str).include?(client_ip)
|
|
782
784
|
end
|
|
783
|
-
rescue IPAddr::InvalidAddressError
|
|
785
|
+
rescue IPAddr::InvalidAddressError, ArgumentError
|
|
784
786
|
false
|
|
785
787
|
end
|
|
786
788
|
|
|
@@ -819,8 +821,7 @@ module Magick
|
|
|
819
821
|
conditions = complex_config[:conditions] || complex_config['conditions'] || []
|
|
820
822
|
operator = (complex_config[:operator] || complex_config['operator'] || :and).to_sym
|
|
821
823
|
|
|
822
|
-
|
|
823
|
-
# Each condition is a hash with type and params
|
|
824
|
+
evaluate_condition = lambda do |condition|
|
|
824
825
|
condition_type = (condition[:type] || condition['type']).to_sym
|
|
825
826
|
condition_params = condition[:params] || condition['params'] || {}
|
|
826
827
|
|
|
@@ -845,9 +846,9 @@ module Magick
|
|
|
845
846
|
|
|
846
847
|
case operator
|
|
847
848
|
when :and, :all
|
|
848
|
-
|
|
849
|
+
conditions.all?(&evaluate_condition)
|
|
849
850
|
when :or, :any
|
|
850
|
-
|
|
851
|
+
conditions.any?(&evaluate_condition)
|
|
851
852
|
else
|
|
852
853
|
false
|
|
853
854
|
end
|
|
@@ -963,8 +964,19 @@ module Magick
|
|
|
963
964
|
end
|
|
964
965
|
|
|
965
966
|
def enable_targeting(type, value)
|
|
966
|
-
|
|
967
|
-
|
|
967
|
+
case type
|
|
968
|
+
when :date_range, :custom_attributes, :complex_conditions, :variants
|
|
969
|
+
# These types store structured data directly (Hash or Array of Hashes)
|
|
970
|
+
@targeting[type] = value
|
|
971
|
+
when :percentage_users, :percentage_requests
|
|
972
|
+
# Numeric types store a single value
|
|
973
|
+
@targeting[type] = value.to_f
|
|
974
|
+
else
|
|
975
|
+
# Array-based types (user, group, role, tag, ip_address)
|
|
976
|
+
@targeting[type] ||= []
|
|
977
|
+
str_value = value.to_s
|
|
978
|
+
@targeting[type] << str_value unless @targeting[type].include?(str_value)
|
|
979
|
+
end
|
|
968
980
|
save_targeting
|
|
969
981
|
|
|
970
982
|
# 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