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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ce19a872aa305d47f9bd4fa75023829cb2a63aafa86809260049cd09e11e696
4
- data.tar.gz: 8a1bed262b71e443384ba60505a2af92ae5f4a1399112730887ec89f6379097a
3
+ metadata.gz: e896cf3753f151fb2f1082a231c9af82095e3caea7716642a9f535e60d36e9e4
4
+ data.tar.gz: 24992a605474319bd831fbd60a6d955ba05de266d7419f60065fef2a2d32d2d1
5
5
  SHA512:
6
- metadata.gz: 441203a6279d12d0ef44043e2145fc591e74cfe849ba186f2c5f6baebc617f78af6a2a6499cfc2c12e24fc0832640ccfd044436a3030bfbbf8d2549f9ff616bb
7
- data.tar.gz: 4370e495deae46281c40f68d81ca1f388fd962297104c6409af7d91a21d6af7eafa5632ec3535c0dc4bcd570b428dd6286b0135a4b2f0c128a2c5e40b8248021
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
- 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
@@ -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
@@ -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
- results = conditions.map do |condition|
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
- results.all?
849
+ conditions.all?(&evaluate_condition)
849
850
  when :or, :any
850
- results.any?
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
- @targeting[type] ||= []
967
- @targeting[type] << value.to_s unless @targeting[type].include?(value.to_s)
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
- 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.1.3'
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.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov