magick-feature-flags 1.4.3 → 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.
- checksums.yaml +4 -4
- data/README.md +63 -3
- data/app/controllers/magick/adminui/features_controller.rb +19 -0
- data/config/magick.rb.example +5 -3
- data/lib/generators/magick/install/templates/magick.rb +5 -3
- data/lib/magick/adapters/registry.rb +8 -2
- data/lib/magick/admin_ui.rb +5 -1
- data/lib/magick/config.rb +10 -4
- data/lib/magick/feature.rb +304 -166
- data/lib/magick/rails/railtie.rb +5 -2
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +205 -51
- data/lib/magick.rb +61 -0
- metadata +1 -1
data/lib/magick/rails/railtie.rb
CHANGED
|
@@ -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
|
data/lib/magick/version.rb
CHANGED
data/lib/magick/versioning.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|