magick-feature-flags 1.4.2 → 1.5.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.
@@ -87,15 +87,18 @@ if defined?(Rails)
87
87
  # Supports both config/features.rb and config/initializers/features.rb
88
88
  config.after_initialize do
89
89
  # Try config/features.rb first (recommended location)
90
+ # definition_mode suppresses audit/version recording: boot replays
91
+ # the declarative definitions in every container, and recording
92
+ # those would flood history with identical snapshots.
90
93
  features_file = Rails.root.join('config', 'features.rb')
91
94
  if File.exist?(features_file)
92
- load features_file
95
+ Magick.definition_mode { load features_file }
93
96
  else
94
97
  # Fallback to config/initializers/features.rb (already loaded by Rails, but check anyway)
95
98
  initializer_file = Rails.root.join('config', 'initializers', 'features.rb')
96
99
  if File.exist?(initializer_file) && !defined?(Magick::Rails::FeaturesLoaded)
97
100
  # Only load if not already loaded (Rails may have already loaded it)
98
- load initializer_file
101
+ Magick.definition_mode { load initializer_file }
99
102
  end
100
103
  end
101
104
  begin
@@ -132,6 +135,18 @@ if defined?(Rails)
132
135
  end
133
136
  end
134
137
 
138
+ # Revive the Pub/Sub subscriber in forked workers. `config.to_prepare`
139
+ # only re-runs per request in development; in production it runs once at
140
+ # boot, BEFORE Puma forks its workers under `preload_app!`. Those workers
141
+ # inherit a dead subscriber thread and would never receive cross-process
142
+ # cache invalidations. This middleware calls `ensure_subscriber!` (a
143
+ # pid-guarded no-op once the subscriber is running) on each request, so a
144
+ # forked worker starts its own subscriber on its first request. In
145
+ # single-mode Puma (no fork) it is effectively free.
146
+ initializer 'magick.subscriber_middleware' do |app|
147
+ app.middleware.use Magick::Rails::SubscriberMiddleware
148
+ end
149
+
135
150
  # Terminate the Pub/Sub subscriber + async metrics thread on process exit.
136
151
  # Without this, Ruby waits on the blocking `Redis#subscribe` call inside
137
152
  # the subscriber thread and Puma/Rails shutdown stalls.
@@ -145,6 +160,26 @@ if defined?(Rails)
145
160
  end
146
161
  end
147
162
 
163
+ # Ensures each process (including Puma workers forked under `preload_app!`)
164
+ # has a live Redis Pub/Sub subscriber for cross-process cache invalidation.
165
+ # `ensure_subscriber!` returns immediately once `@owner_pid == Process.pid`,
166
+ # so the per-request cost is a single pid comparison after the first call.
167
+ class SubscriberMiddleware
168
+ def initialize(app)
169
+ @app = app
170
+ end
171
+
172
+ def call(env)
173
+ begin
174
+ registry = Magick.adapter_registry
175
+ registry.ensure_subscriber! if registry.respond_to?(:ensure_subscriber!)
176
+ rescue StandardError
177
+ # Best-effort: never break a request over subscriber bookkeeping.
178
+ end
179
+ @app.call(env)
180
+ end
181
+ end
182
+
148
183
  # Request store integration
149
184
  module RequestStoreIntegration
150
185
  def self.included(base)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '1.4.2'
4
+ VERSION = '1.5.0'
5
5
  end
@@ -1,15 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'time'
5
+
3
6
  module Magick
4
7
  class Versioning
8
+ DEFAULT_MAX_VERSIONS = 50
9
+
10
+ # Version history lives under a reserved pseudo-feature namespace so that
11
+ # feature reads (get_all_data) never drag snapshot blobs along, and so
12
+ # deleting a feature does not destroy its ActiveRecord archive row.
13
+ STORE_PREFIX = '__magick_versions:'
14
+
5
15
  class Version
6
- attr_reader :version, :feature_data, :timestamp, :created_by
16
+ attr_reader :version, :feature_data, :timestamp, :created_by, :action
7
17
 
8
- def initialize(version, feature_data, created_by: nil)
18
+ def initialize(version, feature_data, created_by: nil, action: nil, timestamp: nil)
9
19
  @version = version
10
20
  @feature_data = feature_data
11
- @timestamp = Time.now
21
+ @timestamp = timestamp || Time.now
12
22
  @created_by = created_by
23
+ @action = action
13
24
  end
14
25
 
15
26
  def to_h
@@ -17,66 +28,65 @@ module Magick
17
28
  version: version,
18
29
  feature_data: feature_data,
19
30
  timestamp: timestamp.iso8601,
20
- created_by: created_by
31
+ created_by: created_by,
32
+ action: action
21
33
  }
22
34
  end
23
35
  end
24
36
 
25
- def initialize(adapter_registry)
37
+ def initialize(adapter_registry, max_versions: DEFAULT_MAX_VERSIONS)
26
38
  @adapter_registry = adapter_registry
27
- @versions = {}
39
+ @max_versions = max_versions.to_i.positive? ? max_versions.to_i : DEFAULT_MAX_VERSIONS
40
+ # Process-local cache of the hot window, rehydrated lazily from the
41
+ # adapters so history survives restarts and is shared across containers.
42
+ @hot = {}
28
43
  @mutex = Mutex.new
29
44
  end
30
45
 
46
+ attr_reader :max_versions
47
+
48
+ # Explicit manual snapshot. Kept as public API from earlier releases;
49
+ # since 1.5.0 every Feature mutation also snapshots automatically.
31
50
  def save_version(feature_name, version: nil, created_by: nil)
32
51
  feature = Magick.features[feature_name.to_s] || Magick[feature_name]
33
- feature_name_str = feature_name.to_s
34
-
35
- # Compute version + append under the same mutex so two concurrent
36
- # save_version calls on the same feature can't both assign version N.
37
- version_data = @mutex.synchronize do
38
- list = (@versions[feature_name_str] ||= [])
39
- resolved_version = version || (list.empty? ? 1 : list.last.version + 1)
40
- entry = Version.new(resolved_version, feature.to_h, created_by: created_by)
41
- list << entry
42
- @adapter_registry.set(feature_name, "version_#{resolved_version}", entry.to_h)
43
- entry
44
- end
52
+ append(feature.name, snapshot_of(feature), version: version, created_by: created_by, action: 'manual')
53
+ end
45
54
 
46
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
47
- Magick::Rails::Events.version_saved(feature_name, version: version_data.version, created_by: created_by)
48
- end
55
+ # Called by Feature#record_change after every successful mutation.
56
+ # snapshot: allows callers (delete) to capture state before the mutation.
57
+ def record_change(feature, action: nil, created_by: nil, snapshot: nil)
58
+ data = snapshot ? deep_symbolize(JSON.parse(JSON.generate(snapshot))) : snapshot_of(feature)
59
+ append(feature.name, data, created_by: created_by, action: action)
60
+ end
61
+
62
+ # Hot window (last max_versions, memory/Redis) by default; all: true
63
+ # merges the unlimited ActiveRecord archive when one is configured.
64
+ def get_versions(feature_name, all: false)
65
+ name = feature_name.to_s
66
+ hot = @mutex.synchronize { hot_window(name).dup }
67
+ return hot unless all
49
68
 
50
- version_data
69
+ older = archive_versions(name).reject { |a| hot.any? { |h| h.version == a.version } }
70
+ (older + hot).sort_by(&:version)
51
71
  end
52
72
 
73
+ # Restore the feature to the snapshot stored in the given version, then
74
+ # record the rollback itself as a new version + audit entry (history only
75
+ # ever rolls forward). Restores state wholesale: value (including false/
76
+ # empty), status, group, the entire targeting hash, and dependencies.
53
77
  def rollback(feature_name, version)
54
- versions = get_versions(feature_name)
55
- target_version = versions.find { |v| v.version == version }
56
- return false unless target_version
78
+ entry = find_version(feature_name, version)
79
+ return false unless entry
57
80
 
58
81
  feature = Magick.features[feature_name.to_s] || Magick[feature_name]
59
- feature_data = target_version.feature_data
60
-
61
- # Restore feature state
62
- feature.set_value(feature_data[:value]) if feature_data[:value]
63
- feature.set_status(feature_data[:status]) if feature_data[:status]
64
-
65
- # Restore targeting
66
- feature_data[:targeting]&.each do |type, values|
67
- Array(values).each do |value|
68
- case type.to_sym
69
- when :user
70
- feature.enable_for_user(value)
71
- when :group
72
- feature.enable_for_group(value)
73
- when :role
74
- feature.enable_for_role(value)
75
- end
76
- end
82
+ Magick.suppress_change_recording do
83
+ feature.restore_snapshot!(entry.feature_data)
77
84
  end
78
85
 
79
- # Rails 8+ event
86
+ actor = Magick.current_actor
87
+ Magick.audit_log&.log(feature_name, 'rollback', user_id: actor, changes: { rolled_back_to: version })
88
+ record_change(feature, action: 'rollback', created_by: actor)
89
+
80
90
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
81
91
  Magick::Rails::Events.rollback(feature_name, version: version)
82
92
  end
@@ -84,17 +94,161 @@ module Magick
84
94
  true
85
95
  end
86
96
 
87
- def get_versions(feature_name)
88
- @mutex.synchronize { (@versions[feature_name.to_s] || []).dup }
97
+ private
98
+
99
+ # Deep copy via JSON so stored snapshots never alias the feature's live
100
+ # targeting hash, and in-process entries match adapter-rehydrated ones.
101
+ def snapshot_of(feature)
102
+ deep_symbolize(JSON.parse(JSON.generate(feature.to_h)))
89
103
  end
90
104
 
91
- private
105
+ def append(name, snapshot, version: nil, created_by: nil, action: nil)
106
+ entry = @mutex.synchronize do
107
+ window = hot_window(name)
108
+ resolved = version || (window.last ? window.last.version + 1 : 1)
109
+ record = Version.new(resolved, snapshot, created_by: created_by, action: action)
110
+ window << record
111
+ window.shift while window.size > @max_versions
112
+ persist_hot_window(name, window)
113
+ persist_archive(name, record)
114
+ record
115
+ end
116
+
117
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
118
+ Magick::Rails::Events.version_saved(name, version: entry.version, created_by: created_by)
119
+ end
120
+
121
+ entry
122
+ end
123
+
124
+ # Callers must hold @mutex.
125
+ def hot_window(name)
126
+ @hot[name] ||= load_hot_window(name)
127
+ end
128
+
129
+ def load_hot_window(name)
130
+ raw = read_hot_list(name)
131
+ raw = read_archive_tail(name) if raw.nil? || raw.empty?
132
+ Array(raw).filter_map { |h| rehydrate(h) }.sort_by(&:version).last(@max_versions)
133
+ end
134
+
135
+ def read_hot_list(name)
136
+ hot_adapters.each do |adapter|
137
+ list = safely { adapter.get(store_name(name), 'versions') }
138
+ list = parse_json(list) if list.is_a?(String)
139
+ return list if list.is_a?(Array) && !list.empty?
140
+ end
141
+ nil
142
+ end
143
+
144
+ # Seed the hot window from the archive when memory/Redis are empty
145
+ # (fresh boot, Redis flush) so version numbering continues, not restarts.
146
+ def read_archive_tail(name)
147
+ adapter = archive_adapter
148
+ return nil unless adapter
149
+
150
+ data = safely { adapter.get_all_data(store_name(name)) }
151
+ return nil unless data.is_a?(Hash)
92
152
 
93
- def next_version(feature_name)
94
- @mutex.synchronize do
95
- list = @versions[feature_name.to_s] || []
96
- list.empty? ? 1 : list.last.version + 1
153
+ data.filter_map { |key, value| value if key.to_s.start_with?('version_') }
154
+ end
155
+
156
+ def archive_versions(name)
157
+ adapter = archive_adapter
158
+ return [] unless adapter
159
+
160
+ data = safely { adapter.get_all_data(store_name(name)) }
161
+ return [] unless data.is_a?(Hash)
162
+
163
+ data.filter_map { |key, value| rehydrate(value) if key.to_s.start_with?('version_') }.sort_by(&:version)
164
+ end
165
+
166
+ def persist_hot_window(name, window)
167
+ payload = window.map(&:to_h)
168
+ hot_adapters.each do |adapter|
169
+ safely { adapter.set(store_name(name), 'versions', payload) }
97
170
  end
98
171
  end
172
+
173
+ def persist_archive(name, entry)
174
+ adapter = archive_adapter
175
+ return unless adapter
176
+
177
+ safely { adapter.set(store_name(name), "version_#{entry.version}", entry.to_h) }
178
+ end
179
+
180
+ def find_version(feature_name, version)
181
+ name = feature_name.to_s
182
+ hot = @mutex.synchronize { hot_window(name).dup }
183
+ found = hot.find { |v| v.version == version }
184
+ return found if found
185
+
186
+ adapter = archive_adapter
187
+ return nil unless adapter
188
+
189
+ raw = safely { adapter.get(store_name(name), "version_#{version}") }
190
+ raw ? rehydrate(raw) : nil
191
+ end
192
+
193
+ # Hot window lives in memory + Redis (capped); the ActiveRecord adapter
194
+ # keeps the unlimited archive, one version_<n> key per entry.
195
+ def hot_adapters
196
+ registry = @adapter_registry
197
+ if registry.respond_to?(:memory_adapter)
198
+ [registry.memory_adapter, registry.redis_adapter].compact
199
+ else
200
+ [registry]
201
+ end
202
+ end
203
+
204
+ def archive_adapter
205
+ registry = @adapter_registry
206
+ registry.respond_to?(:active_record_adapter) ? registry.active_record_adapter : nil
207
+ end
208
+
209
+ def store_name(name)
210
+ "#{STORE_PREFIX}#{name}"
211
+ end
212
+
213
+ def rehydrate(raw)
214
+ raw = parse_json(raw) if raw.is_a?(String)
215
+ return nil unless raw.is_a?(Hash)
216
+
217
+ data = deep_symbolize(raw)
218
+ timestamp = data[:timestamp]
219
+ timestamp = safely { Time.parse(timestamp) } if timestamp.is_a?(String)
220
+ Version.new(
221
+ data[:version],
222
+ data[:feature_data] || {},
223
+ created_by: data[:created_by],
224
+ action: data[:action],
225
+ timestamp: timestamp
226
+ )
227
+ end
228
+
229
+ def parse_json(str)
230
+ JSON.parse(str)
231
+ rescue JSON::ParserError
232
+ nil
233
+ end
234
+
235
+ def deep_symbolize(obj)
236
+ case obj
237
+ when Hash
238
+ obj.each_with_object({}) { |(k, v), out| out[k.to_sym] = deep_symbolize(v) }
239
+ when Array
240
+ obj.map { |v| deep_symbolize(v) }
241
+ else
242
+ obj
243
+ end
244
+ end
245
+
246
+ # Version bookkeeping is best-effort: a flaky adapter must never turn a
247
+ # feature toggle into an exception.
248
+ def safely
249
+ yield
250
+ rescue StandardError
251
+ nil
252
+ end
99
253
  end
100
254
  end
data/lib/magick.rb CHANGED
@@ -216,6 +216,64 @@ module Magick
216
216
  @versioning ||= Versioning.new(adapter_registry || default_adapter_registry)
217
217
  end
218
218
 
219
+ # When false, Feature#record_change skips version snapshots (audit log
220
+ # entries are still written). Set via `versioning enabled: false` in the
221
+ # configuration DSL.
222
+ attr_writer :versioning_enabled
223
+
224
+ def versioning_enabled?
225
+ @versioning_enabled.nil? || @versioning_enabled != false
226
+ end
227
+
228
+ # Attribute all changes made inside the block to the given actor. Audit
229
+ # entries pick it up as user_id and versions as created_by, unless the
230
+ # call site passes an explicit user_id:.
231
+ #
232
+ # Magick.with_actor(current_user.id) { Magick[:checkout].enable }
233
+ def with_actor(actor)
234
+ previous = Thread.current[:magick_actor]
235
+ Thread.current[:magick_actor] = actor
236
+ yield
237
+ ensure
238
+ Thread.current[:magick_actor] = previous
239
+ end
240
+
241
+ def current_actor
242
+ Thread.current[:magick_actor]
243
+ end
244
+
245
+ # Suppress audit/version recording while declarative feature definitions
246
+ # are (re)applied. Process boot replays config/features.rb in every
247
+ # container; recording those replays would flood history with identical
248
+ # snapshots. The Rails railtie wraps the features file load in this;
249
+ # non-Rails apps should do the same around their definition file.
250
+ def definition_mode
251
+ previous = Thread.current[:magick_definition_mode]
252
+ Thread.current[:magick_definition_mode] = true
253
+ yield
254
+ ensure
255
+ Thread.current[:magick_definition_mode] = previous
256
+ end
257
+
258
+ def definition_mode?
259
+ Thread.current[:magick_definition_mode] == true
260
+ end
261
+
262
+ # Reentrancy guard for Feature#record_change: the outermost public
263
+ # mutator records once; nested mutator calls (enable -> set_value) run
264
+ # silently so one logical operation never produces multiple entries.
265
+ def suppress_change_recording
266
+ previous = Thread.current[:magick_change_recording]
267
+ Thread.current[:magick_change_recording] = true
268
+ yield
269
+ ensure
270
+ Thread.current[:magick_change_recording] = previous
271
+ end
272
+
273
+ def change_recording_suppressed?
274
+ Thread.current[:magick_change_recording] == true || definition_mode?
275
+ end
276
+
219
277
  # Manually enable Redis tracking for performance metrics
220
278
  # Useful if Redis adapter becomes available after initial configuration
221
279
  def enable_redis_tracking(enable: true)
@@ -280,6 +338,9 @@ module Magick
280
338
  @adapter_registry = nil
281
339
  @default_adapter = nil
282
340
  @default_adapter_registry = nil
341
+ @versioning = nil
342
+ @audit_log = nil
343
+ @versioning_enabled = nil
283
344
  @performance_metrics&.clear!
284
345
  end
285
346
 
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.4.2
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov