magick-feature-flags 0.7.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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +363 -0
  4. data/lib/generators/magick/install/install_generator.rb +19 -0
  5. data/lib/generators/magick/install/templates/README +25 -0
  6. data/lib/generators/magick/install/templates/magick.rb +32 -0
  7. data/lib/magick/adapters/base.rb +27 -0
  8. data/lib/magick/adapters/memory.rb +113 -0
  9. data/lib/magick/adapters/redis.rb +97 -0
  10. data/lib/magick/adapters/registry.rb +133 -0
  11. data/lib/magick/audit_log.rb +65 -0
  12. data/lib/magick/circuit_breaker.rb +65 -0
  13. data/lib/magick/config.rb +179 -0
  14. data/lib/magick/dsl.rb +80 -0
  15. data/lib/magick/errors.rb +9 -0
  16. data/lib/magick/export_import.rb +82 -0
  17. data/lib/magick/feature.rb +665 -0
  18. data/lib/magick/feature_dependency.rb +28 -0
  19. data/lib/magick/feature_variant.rb +17 -0
  20. data/lib/magick/performance_metrics.rb +76 -0
  21. data/lib/magick/rails/event_subscriber.rb +55 -0
  22. data/lib/magick/rails/events.rb +236 -0
  23. data/lib/magick/rails/railtie.rb +94 -0
  24. data/lib/magick/rails.rb +7 -0
  25. data/lib/magick/targeting/base.rb +11 -0
  26. data/lib/magick/targeting/complex.rb +27 -0
  27. data/lib/magick/targeting/custom_attribute.rb +35 -0
  28. data/lib/magick/targeting/date_range.rb +17 -0
  29. data/lib/magick/targeting/group.rb +15 -0
  30. data/lib/magick/targeting/ip_address.rb +22 -0
  31. data/lib/magick/targeting/percentage.rb +24 -0
  32. data/lib/magick/targeting/request_percentage.rb +15 -0
  33. data/lib/magick/targeting/role.rb +15 -0
  34. data/lib/magick/targeting/user.rb +15 -0
  35. data/lib/magick/testing_helpers.rb +45 -0
  36. data/lib/magick/version.rb +5 -0
  37. data/lib/magick/versioning.rb +98 -0
  38. data/lib/magick.rb +143 -0
  39. metadata +123 -0
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Adapters
5
+ class Registry
6
+ CACHE_INVALIDATION_CHANNEL = 'magick:cache:invalidate'.freeze
7
+
8
+ def initialize(memory_adapter, redis_adapter = nil, circuit_breaker: nil, async: false)
9
+ @memory_adapter = memory_adapter
10
+ @redis_adapter = redis_adapter
11
+ @circuit_breaker = circuit_breaker || Magick::CircuitBreaker.new
12
+ @async = async
13
+ @subscriber_thread = nil
14
+ @subscriber = nil
15
+ # Only start Pub/Sub subscriber if Redis is available
16
+ # In memory-only mode, each process has isolated cache (no cross-process invalidation)
17
+ start_cache_invalidation_subscriber if redis_adapter
18
+ end
19
+
20
+ def get(feature_name, key)
21
+ # Try memory first (fastest) - no Redis calls needed thanks to Pub/Sub invalidation
22
+ value = memory_adapter.get(feature_name, key)
23
+ return value unless value.nil?
24
+
25
+ # Fall back to Redis if available
26
+ if redis_adapter
27
+ begin
28
+ value = redis_adapter.get(feature_name, key)
29
+ # Update memory cache if found in Redis
30
+ memory_adapter.set(feature_name, key, value) if value
31
+ return value
32
+ rescue AdapterError
33
+ # Redis failed, return nil
34
+ nil
35
+ end
36
+ end
37
+
38
+ nil
39
+ end
40
+
41
+ def set(feature_name, key, value)
42
+ # Update memory first (always synchronous)
43
+ memory_adapter.set(feature_name, key, value)
44
+
45
+ # Update Redis if available
46
+ if redis_adapter
47
+ update_redis = proc do
48
+ circuit_breaker.call do
49
+ redis_adapter.set(feature_name, key, value)
50
+ # Publish cache invalidation message to notify other processes
51
+ publish_cache_invalidation(feature_name)
52
+ end
53
+ rescue AdapterError => e
54
+ # Log error but don't fail - memory is updated
55
+ warn "Failed to update Redis: #{e.message}" if defined?(Rails) && Rails.env.development?
56
+ end
57
+
58
+ if @async && defined?(Thread)
59
+ Thread.new { update_redis.call }
60
+ else
61
+ update_redis.call
62
+ end
63
+ end
64
+ end
65
+
66
+ def delete(feature_name)
67
+ memory_adapter.delete(feature_name)
68
+ if redis_adapter
69
+ begin
70
+ redis_adapter.delete(feature_name)
71
+ # Publish cache invalidation message
72
+ publish_cache_invalidation(feature_name)
73
+ rescue AdapterError
74
+ # Continue even if Redis fails
75
+ end
76
+ end
77
+ end
78
+
79
+ def exists?(feature_name)
80
+ memory_adapter.exists?(feature_name) || (redis_adapter&.exists?(feature_name) == true)
81
+ end
82
+
83
+ def all_features
84
+ memory_features = memory_adapter.all_features
85
+ redis_features = redis_adapter&.all_features || []
86
+ (memory_features + redis_features).uniq
87
+ end
88
+
89
+ private
90
+
91
+ attr_reader :memory_adapter, :redis_adapter, :circuit_breaker
92
+
93
+ # Publish cache invalidation message to Redis Pub/Sub
94
+ def publish_cache_invalidation(feature_name)
95
+ return unless redis_adapter
96
+
97
+ begin
98
+ redis_client = redis_adapter.instance_variable_get(:@redis)
99
+ redis_client&.publish(CACHE_INVALIDATION_CHANNEL, feature_name.to_s)
100
+ rescue StandardError => e
101
+ # Silently fail - cache invalidation is best effort
102
+ warn "Failed to publish cache invalidation: #{e.message}" if defined?(Rails) && Rails.env.development?
103
+ end
104
+ end
105
+
106
+ # Start a background thread to listen for cache invalidation messages
107
+ def start_cache_invalidation_subscriber
108
+ return unless redis_adapter && defined?(Thread)
109
+
110
+ @subscriber_thread = Thread.new do
111
+ begin
112
+ redis_client = redis_adapter.instance_variable_get(:@redis)
113
+ return unless redis_client
114
+
115
+ @subscriber = redis_client.dup
116
+ @subscriber.subscribe(CACHE_INVALIDATION_CHANNEL) do |on|
117
+ on.message do |_channel, feature_name|
118
+ # Invalidate memory cache for this feature
119
+ memory_adapter.delete(feature_name)
120
+ end
121
+ end
122
+ rescue StandardError => e
123
+ # If subscription fails, log and retry after a delay
124
+ warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
125
+ sleep 5
126
+ retry
127
+ end
128
+ end
129
+ @subscriber_thread.abort_on_exception = false
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ class AuditLog
5
+ class Entry
6
+ attr_reader :feature_name, :action, :user_id, :timestamp, :changes, :metadata
7
+
8
+ def initialize(feature_name, action, user_id: nil, changes: {}, metadata: {})
9
+ @feature_name = feature_name.to_s
10
+ @action = action.to_s
11
+ @user_id = user_id
12
+ @timestamp = Time.now
13
+ @changes = changes
14
+ @metadata = metadata
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ feature_name: feature_name,
20
+ action: action,
21
+ user_id: user_id,
22
+ timestamp: timestamp.iso8601,
23
+ changes: changes,
24
+ metadata: metadata
25
+ }
26
+ end
27
+ end
28
+
29
+ def initialize(adapter = nil)
30
+ @adapter = adapter || default_adapter
31
+ @logs = []
32
+ @mutex = Mutex.new
33
+ end
34
+
35
+ def log(feature_name, action, user_id: nil, changes: {}, metadata: {})
36
+ entry = Entry.new(feature_name, action, user_id: user_id, changes: changes, metadata: metadata)
37
+ @mutex.synchronize do
38
+ @logs << entry
39
+ @adapter.append(entry) if @adapter.respond_to?(:append)
40
+ end
41
+
42
+ # Rails 8+ event
43
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
44
+ Magick::Rails::Events.audit_logged(feature_name, action: action, user_id: user_id, changes: changes, **metadata)
45
+ end
46
+
47
+ entry
48
+ end
49
+
50
+ def entries(feature_name: nil, limit: 100)
51
+ result = @logs
52
+ result = result.select { |e| e.feature_name == feature_name.to_s } if feature_name
53
+ result.last(limit)
54
+ end
55
+
56
+ private
57
+
58
+ def default_adapter
59
+ # Default to in-memory storage
60
+ Class.new do
61
+ def append(_entry); end
62
+ end.new
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ class CircuitBreaker
5
+ DEFAULT_FAILURE_THRESHOLD = 5
6
+ DEFAULT_TIMEOUT = 60
7
+
8
+ attr_reader :failure_count, :last_failure_time, :state
9
+
10
+ def initialize(failure_threshold: DEFAULT_FAILURE_THRESHOLD, timeout: DEFAULT_TIMEOUT)
11
+ @failure_threshold = failure_threshold
12
+ @timeout = timeout
13
+ @failure_count = 0
14
+ @last_failure_time = nil
15
+ @state = :closed
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def call
20
+ return false if open?
21
+
22
+ begin
23
+ result = yield
24
+ record_success
25
+ result
26
+ rescue StandardError => e
27
+ record_failure
28
+ raise e
29
+ end
30
+ end
31
+
32
+ def open?
33
+ @mutex.synchronize do
34
+ if @state == :open
35
+ if Time.now.to_i - @last_failure_time.to_i > @timeout
36
+ @state = :half_open
37
+ @failure_count = 0
38
+ false
39
+ else
40
+ true
41
+ end
42
+ else
43
+ false
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def record_success
51
+ @mutex.synchronize do
52
+ @failure_count = 0
53
+ @state = :closed if @state == :half_open
54
+ end
55
+ end
56
+
57
+ def record_failure
58
+ @mutex.synchronize do
59
+ @failure_count += 1
60
+ @last_failure_time = Time.now
61
+ @state = :open if @failure_count >= @failure_threshold
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ class Config
5
+ attr_accessor :adapter_registry, :performance_metrics, :audit_log, :versioning
6
+ attr_accessor :warn_on_deprecated, :async_updates, :memory_ttl, :circuit_breaker_threshold
7
+ attr_accessor :circuit_breaker_timeout, :redis_url, :redis_namespace, :environment
8
+
9
+ def initialize
10
+ @warn_on_deprecated = false
11
+ @async_updates = false
12
+ @memory_ttl = 3600 # 1 hour
13
+ @circuit_breaker_threshold = 5
14
+ @circuit_breaker_timeout = 60
15
+ @redis_namespace = 'magick:features'
16
+ @environment = defined?(Rails) ? Rails.env.to_s : 'development'
17
+ end
18
+
19
+ # DSL methods for configuration
20
+ def adapter(type, **options, &block)
21
+ case type.to_sym
22
+ when :memory
23
+ configure_memory_adapter(**options)
24
+ when :redis
25
+ configure_redis_adapter(**options)
26
+ when :registry
27
+ if block_given?
28
+ instance_eval(&block)
29
+ configure_registry_adapter
30
+ else
31
+ configure_registry_adapter(**options)
32
+ end
33
+ else
34
+ raise ArgumentError, "Unknown adapter type: #{type}"
35
+ end
36
+ end
37
+
38
+ def memory(**options)
39
+ configure_memory_adapter(**options)
40
+ end
41
+
42
+ def redis(url: nil, namespace: nil, **options)
43
+ @redis_url = url if url
44
+ @redis_namespace = namespace if namespace
45
+ configure_redis_adapter(url: url, namespace: namespace, **options)
46
+ end
47
+
48
+ def performance_metrics(enabled: true, **options)
49
+ if enabled
50
+ @performance_metrics = PerformanceMetrics.new
51
+ else
52
+ @performance_metrics = nil
53
+ end
54
+ end
55
+
56
+ def audit_log(enabled: true, adapter: nil)
57
+ if enabled
58
+ @audit_log = adapter ? AuditLog.new(adapter) : AuditLog.new
59
+ else
60
+ @audit_log = nil
61
+ end
62
+ end
63
+
64
+ def versioning(enabled: true)
65
+ if enabled
66
+ @versioning = Versioning.new(adapter_registry || default_adapter_registry)
67
+ else
68
+ @versioning = nil
69
+ end
70
+ end
71
+
72
+ def circuit_breaker(threshold: nil, timeout: nil)
73
+ @circuit_breaker_threshold = threshold if threshold
74
+ @circuit_breaker_timeout = timeout if timeout
75
+ end
76
+
77
+ def async_updates(enabled: true)
78
+ @async_updates = enabled
79
+ end
80
+
81
+ def memory_ttl(seconds)
82
+ @memory_ttl = seconds
83
+ end
84
+
85
+ def warn_on_deprecated(enabled: true)
86
+ @warn_on_deprecated = enabled
87
+ end
88
+
89
+ def environment(name)
90
+ @environment = name.to_s
91
+ end
92
+
93
+ def apply!
94
+ # Apply configuration to Magick module
95
+ Magick.adapter_registry = adapter_registry if adapter_registry
96
+ Magick.performance_metrics = performance_metrics if performance_metrics
97
+ Magick.audit_log = audit_log if audit_log
98
+ Magick.versioning = versioning if versioning
99
+ Magick.warn_on_deprecated = warn_on_deprecated
100
+ end
101
+
102
+ private
103
+
104
+ def configure_memory_adapter(ttl: nil)
105
+ ttl ||= @memory_ttl
106
+ adapter = Adapters::Memory.new
107
+ # Set default TTL by updating the adapter's default_ttl
108
+ adapter.instance_variable_set(:@default_ttl, ttl) if ttl
109
+ adapter
110
+ end
111
+
112
+ def configure_redis_adapter(url: nil, namespace: nil, client: nil)
113
+ return nil unless defined?(Redis)
114
+
115
+ url ||= @redis_url
116
+ namespace ||= @redis_namespace
117
+
118
+ redis_client = client || begin
119
+ if url
120
+ ::Redis.new(url: url)
121
+ else
122
+ ::Redis.new
123
+ end
124
+ rescue StandardError
125
+ nil
126
+ end
127
+
128
+ return nil unless redis_client
129
+
130
+ adapter = Adapters::Redis.new(redis_client)
131
+ adapter.instance_variable_set(:@namespace, namespace) if namespace
132
+ adapter
133
+ end
134
+
135
+ def configure_registry_adapter(memory: nil, redis: nil, async: nil, circuit_breaker: nil)
136
+ memory_adapter = memory || configure_memory_adapter
137
+ redis_adapter = redis || configure_redis_adapter
138
+
139
+ cb = circuit_breaker || CircuitBreaker.new(
140
+ failure_threshold: @circuit_breaker_threshold,
141
+ timeout: @circuit_breaker_timeout
142
+ )
143
+
144
+ async_enabled = async.nil? ? @async_updates : async
145
+
146
+ @adapter_registry = Adapters::Registry.new(
147
+ memory_adapter,
148
+ redis_adapter,
149
+ circuit_breaker: cb,
150
+ async: async_enabled
151
+ )
152
+ end
153
+
154
+ def default_adapter_registry
155
+ @default_adapter_registry ||= begin
156
+ memory_adapter = Adapters::Memory.new
157
+ redis_adapter = configure_redis_adapter
158
+ Adapters::Registry.new(memory_adapter, redis_adapter)
159
+ end
160
+ end
161
+ end
162
+
163
+ # DSL for configuration
164
+ module ConfigDSL
165
+ def self.configure(&block)
166
+ config = Config.new
167
+ config.instance_eval(&block)
168
+ config.apply!
169
+ config
170
+ end
171
+
172
+ def self.load_from_file(file_path)
173
+ config = Config.new
174
+ config.instance_eval(File.read(file_path), file_path)
175
+ config.apply!
176
+ config
177
+ end
178
+ end
179
+ end
data/lib/magick/dsl.rb ADDED
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module DSL
5
+ # Feature definition DSL methods
6
+ def feature(name, **options)
7
+ Magick.register_feature(name, **options)
8
+ end
9
+
10
+ def boolean_feature(name, default: false, **options)
11
+ Magick.register_feature(name, type: :boolean, default_value: default, **options)
12
+ end
13
+
14
+ def string_feature(name, default: '', **options)
15
+ Magick.register_feature(name, type: :string, default_value: default, **options)
16
+ end
17
+
18
+ def number_feature(name, default: 0, **options)
19
+ Magick.register_feature(name, type: :number, default_value: default, **options)
20
+ end
21
+
22
+ # Targeting DSL methods
23
+ def enable_for_user(feature_name, user_id)
24
+ Magick[feature_name].enable_for_user(user_id)
25
+ end
26
+
27
+ def enable_for_group(feature_name, group_name)
28
+ Magick[feature_name].enable_for_group(group_name)
29
+ end
30
+
31
+ def enable_for_role(feature_name, role_name)
32
+ Magick[feature_name].enable_for_role(role_name)
33
+ end
34
+
35
+ def enable_percentage(feature_name, percentage, type: :users)
36
+ feature = Magick[feature_name]
37
+ case type
38
+ when :users
39
+ feature.enable_percentage_of_users(percentage)
40
+ when :requests
41
+ feature.enable_percentage_of_requests(percentage)
42
+ end
43
+ end
44
+
45
+ def enable_for_date_range(feature_name, start_date, end_date)
46
+ Magick[feature_name].enable_for_date_range(start_date, end_date)
47
+ end
48
+
49
+ def enable_for_ip_addresses(feature_name, *ip_addresses)
50
+ Magick[feature_name].enable_for_ip_addresses(ip_addresses)
51
+ end
52
+
53
+ def enable_for_custom_attribute(feature_name, attribute_name, values, operator: :equals)
54
+ Magick[feature_name].enable_for_custom_attribute(attribute_name, values, operator: operator)
55
+ end
56
+
57
+ def set_variants(feature_name, variants)
58
+ Magick[feature_name].set_variants(variants)
59
+ end
60
+
61
+ def add_dependency(feature_name, dependency_name)
62
+ Magick[feature_name].add_dependency(dependency_name)
63
+ end
64
+
65
+ # Enable/disable features globally
66
+ def enable_feature(feature_name, user_id: nil)
67
+ Magick[feature_name].enable(user_id: user_id)
68
+ end
69
+
70
+ def disable_feature(feature_name, user_id: nil)
71
+ Magick[feature_name].disable(user_id: user_id)
72
+ end
73
+ end
74
+ end
75
+
76
+ # Make DSL methods available at top level for config/features.rb and config/initializers/features.rb
77
+ # Include into Object so methods are available as instance methods on main (top-level context)
78
+ Object.class_eval do
79
+ include Magick::DSL unless included_modules.include?(Magick::DSL)
80
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ class Error < StandardError; end
5
+ class FeatureNotFoundError < Error; end
6
+ class InvalidFeatureTypeError < Error; end
7
+ class InvalidFeatureValueError < Error; end
8
+ class AdapterError < Error; end
9
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Magick
6
+ class ExportImport
7
+ def self.export(features_hash)
8
+ result = features_hash.map do |name, feature|
9
+ feature.to_h
10
+ end
11
+
12
+ # Rails 8+ event
13
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
14
+ Magick::Rails::Events.exported(format: :hash, feature_count: result.length)
15
+ end
16
+
17
+ result
18
+ end
19
+
20
+ def self.export_json(features_hash)
21
+ result = JSON.pretty_generate(export(features_hash))
22
+
23
+ # Rails 8+ event
24
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
25
+ Magick::Rails::Events.exported(format: :json, feature_count: features_hash.length)
26
+ end
27
+
28
+ result
29
+ end
30
+
31
+ def self.import(data, adapter_registry)
32
+ features = {}
33
+ data = JSON.parse(data) if data.is_a?(String)
34
+
35
+ Array(data).each do |feature_data|
36
+ name = feature_data['name'] || feature_data[:name]
37
+ next unless name
38
+
39
+ feature = Feature.new(
40
+ name,
41
+ adapter_registry,
42
+ type: (feature_data['type'] || feature_data[:type] || :boolean).to_sym,
43
+ status: (feature_data['status'] || feature_data[:status] || :active).to_sym,
44
+ default_value: feature_data['default_value'] || feature_data[:default_value],
45
+ description: feature_data['description'] || feature_data[:description]
46
+ )
47
+
48
+ feature.set_value(feature_data['value'] || feature_data[:value]) if feature_data['value'] || feature_data[:value]
49
+
50
+ # Import targeting
51
+ if feature_data['targeting'] || feature_data[:targeting]
52
+ targeting = feature_data['targeting'] || feature_data[:targeting]
53
+ targeting.each do |type, values|
54
+ Array(values).each do |value|
55
+ case type.to_sym
56
+ when :user
57
+ feature.enable_for_user(value)
58
+ when :group
59
+ feature.enable_for_group(value)
60
+ when :role
61
+ feature.enable_for_role(value)
62
+ when :percentage_users
63
+ feature.enable_percentage_of_users(value)
64
+ when :percentage_requests
65
+ feature.enable_percentage_of_requests(value)
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ features[name.to_s] = feature
72
+ end
73
+
74
+ # Rails 8+ event
75
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
76
+ Magick::Rails::Events.imported(format: format, feature_count: features.length)
77
+ end
78
+
79
+ features
80
+ end
81
+ end
82
+ end