magick-feature-flags 1.3.0 → 1.3.2
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 +26 -0
- data/app/controllers/magick/adminui/features_controller.rb +5 -2
- data/lib/magick/adapters/registry.rb +54 -1
- data/lib/magick/feature.rb +14 -19
- data/lib/magick/rails/railtie.rb +11 -0
- data/lib/magick/version.rb +1 -1
- data/lib/magick.rb +19 -0
- 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: 58eabf5855bcd4578d09090c65cc5b8d1b33ba2caa38ae7dd85bf7ecebb76047
|
|
4
|
+
data.tar.gz: e57e675ff58e913e60ef883b4dd8078087c7575ef5ced4355c8811bb15bf2dd3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3fdaf5955fcf00d83047ff4ed785476b91b89e9d53ad80d890459e77134cf974cfe53a204060e471c491f133cd4d88f88e0f91813e042e5bf311821e09d804ea
|
|
7
|
+
data.tar.gz: d09483c77bb6af18a7f24565366d08e28a725c95d741c8cdcbf0ae3abcd17d8c3e0626942668a41f9041120a786f0463d35567c4ccfaaca3f56826f2888aa682
|
data/README.md
CHANGED
|
@@ -467,6 +467,32 @@ class CheckoutController < ApplicationController
|
|
|
467
467
|
end
|
|
468
468
|
```
|
|
469
469
|
|
|
470
|
+
**Experiments without a user (anonymous visitors):**
|
|
471
|
+
|
|
472
|
+
For flows where there's no authenticated user yet (e.g., registration, landing pages), use any stable identifier as `user_id` — a session ID or a tracking cookie:
|
|
473
|
+
|
|
474
|
+
```ruby
|
|
475
|
+
class RegistrationController < ApplicationController
|
|
476
|
+
def new
|
|
477
|
+
cookies[:visitor_id] ||= SecureRandom.uuid
|
|
478
|
+
@variant = Magick.variant(:registration_flow, user_id: cookies[:visitor_id])
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
The hashing just needs a consistent string. As long as the same visitor sends the same identifier, they get the same variant every time.
|
|
484
|
+
|
|
485
|
+
**Safe to call on non-existent experiments:**
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
Magick.variant(:nonexistent, user_id: 123) # => nil
|
|
489
|
+
Magick.variant_value(:nonexistent, user_id: 123) # => nil
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**Important — changing weights may shift users:**
|
|
493
|
+
|
|
494
|
+
You can change variant weights at any time via the Admin UI or code, and changes take effect immediately across all adapters. However, changing weights alters the bucket boundaries, which means some users may be reassigned to a different variant after the update. Magick does not persist individual user-to-variant assignments — assignment is computed on the fly from the hash. If your experiment requires that users never shift variants mid-experiment, you should persist the assignment externally (e.g., store `user_id → variant` in a database table on first exposure).
|
|
495
|
+
|
|
470
496
|
**How it works:**
|
|
471
497
|
- Variants are assigned using a deterministic MD5 hash of `feature_name + user_id`
|
|
472
498
|
- The same user always gets the same variant across sessions and requests
|
|
@@ -80,8 +80,11 @@ module Magick
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def enable
|
|
83
|
-
@feature.enable
|
|
84
|
-
|
|
83
|
+
if @feature.enable
|
|
84
|
+
redirect_to magick_admin_ui.features_path, notice: 'Feature enabled'
|
|
85
|
+
else
|
|
86
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Cannot enable feature — its dependencies must be enabled first'
|
|
87
|
+
end
|
|
85
88
|
end
|
|
86
89
|
|
|
87
90
|
def disable
|
|
@@ -21,11 +21,34 @@ module Magick
|
|
|
21
21
|
@last_reload_times = {} # Track last reload time per feature for debouncing
|
|
22
22
|
@local_writes = {} # Track recent local writes to skip self-invalidation
|
|
23
23
|
@reload_mutex = Mutex.new
|
|
24
|
+
@stopping = false
|
|
25
|
+
@shutdown_mutex = Mutex.new
|
|
24
26
|
# Only start Pub/Sub subscriber if Redis is available
|
|
25
27
|
# In memory-only mode, each process has isolated cache (no cross-process invalidation)
|
|
26
28
|
start_cache_invalidation_subscriber if redis_adapter
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# Gracefully terminate the Pub/Sub subscriber thread and its Redis connection.
|
|
32
|
+
# Without this, Ruby/Puma shutdown waits on the blocking `subscribe` call.
|
|
33
|
+
def shutdown(timeout: 5)
|
|
34
|
+
@shutdown_mutex.synchronize do
|
|
35
|
+
return if @stopping
|
|
36
|
+
|
|
37
|
+
@stopping = true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
close_subscriber_connection(@subscriber)
|
|
41
|
+
terminate_subscriber_thread(@subscriber_thread, timeout)
|
|
42
|
+
|
|
43
|
+
@subscriber = nil
|
|
44
|
+
@subscriber_thread = nil
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stopping?
|
|
49
|
+
@stopping == true
|
|
50
|
+
end
|
|
51
|
+
|
|
29
52
|
def get(feature_name, key)
|
|
30
53
|
# Try memory first (fastest) - no Redis calls needed thanks to Pub/Sub invalidation
|
|
31
54
|
value = memory_adapter.get(feature_name, key) if memory_adapter
|
|
@@ -288,6 +311,32 @@ module Magick
|
|
|
288
311
|
|
|
289
312
|
attr_reader :memory_adapter, :redis_adapter, :active_record_adapter, :circuit_breaker
|
|
290
313
|
|
|
314
|
+
# Signal the subscribe loop to return, then close the connection so any
|
|
315
|
+
# retry/reconnect attempt fails fast instead of sleeping for 5s.
|
|
316
|
+
def close_subscriber_connection(subscriber)
|
|
317
|
+
return unless subscriber
|
|
318
|
+
|
|
319
|
+
begin
|
|
320
|
+
subscriber.unsubscribe(CACHE_INVALIDATION_CHANNEL)
|
|
321
|
+
rescue StandardError
|
|
322
|
+
# connection may already be dead; fall through to close/kill
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
begin
|
|
326
|
+
subscriber.close
|
|
327
|
+
rescue StandardError
|
|
328
|
+
# ignore: best-effort close
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def terminate_subscriber_thread(thread, timeout)
|
|
333
|
+
return unless thread
|
|
334
|
+
return if thread.join(timeout)
|
|
335
|
+
|
|
336
|
+
thread.kill
|
|
337
|
+
thread.join(1) # give it a moment to actually unwind
|
|
338
|
+
end
|
|
339
|
+
|
|
291
340
|
# Record that this process just wrote a feature, so the subscriber
|
|
292
341
|
# ignores its own Pub/Sub messages and doesn't revert the correct in-memory state.
|
|
293
342
|
def record_local_write(feature_name)
|
|
@@ -421,9 +470,13 @@ module Magick
|
|
|
421
470
|
(defined?(Rails) && Rails.env.test?)
|
|
422
471
|
return if is_rspec_error
|
|
423
472
|
|
|
473
|
+
# Stop cleanly during app shutdown instead of sleeping + retrying,
|
|
474
|
+
# which would keep the process alive and delay termination.
|
|
475
|
+
return if @stopping
|
|
476
|
+
|
|
424
477
|
warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
425
478
|
sleep 5
|
|
426
|
-
retry
|
|
479
|
+
retry unless @stopping
|
|
427
480
|
end
|
|
428
481
|
@subscriber_thread.abort_on_exception = false
|
|
429
482
|
end
|
data/lib/magick/feature.rb
CHANGED
|
@@ -516,20 +516,16 @@ module Magick
|
|
|
516
516
|
end
|
|
517
517
|
|
|
518
518
|
def enable(user_id: nil)
|
|
519
|
-
# Check
|
|
520
|
-
#
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
end
|
|
519
|
+
# Check that all of this feature's own dependencies are enabled
|
|
520
|
+
# e.g. if checkout depends on payments, checkout can't be enabled until payments is
|
|
521
|
+
deps = @dependencies || []
|
|
522
|
+
unless deps.empty?
|
|
523
|
+
disabled_deps = deps.select do |dep_name|
|
|
524
|
+
dep_feature = Magick.features[dep_name.to_s] || Magick[dep_name]
|
|
525
|
+
dep_feature && !dep_feature.enabled?
|
|
526
|
+
end
|
|
528
527
|
|
|
529
|
-
|
|
530
|
-
# Return false if any main feature that depends on this feature is disabled
|
|
531
|
-
# This prevents enabling a dependency when the main feature is disabled
|
|
532
|
-
return false
|
|
528
|
+
return false unless disabled_deps.empty?
|
|
533
529
|
end
|
|
534
530
|
|
|
535
531
|
# Clear all targeting to enable globally
|
|
@@ -988,13 +984,12 @@ module Magick
|
|
|
988
984
|
end
|
|
989
985
|
|
|
990
986
|
def disable_dependent_features(user_id: nil)
|
|
991
|
-
# Cascade-disable
|
|
992
|
-
# e.g. if
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
return if deps.empty?
|
|
987
|
+
# Cascade-disable features that depend ON this feature.
|
|
988
|
+
# e.g. if checkout depends on payments, disabling payments also disables checkout.
|
|
989
|
+
dependents = find_dependent_features
|
|
990
|
+
return if dependents.empty?
|
|
996
991
|
|
|
997
|
-
|
|
992
|
+
dependents.each do |dep_name|
|
|
998
993
|
dep_feature = Magick.features[dep_name.to_s] || Magick[dep_name]
|
|
999
994
|
next unless dep_feature
|
|
1000
995
|
|
data/lib/magick/rails/railtie.rb
CHANGED
|
@@ -125,6 +125,17 @@ if defined?(Rails)
|
|
|
125
125
|
Magick.performance_metrics.enable_redis_tracking(enable: true)
|
|
126
126
|
end
|
|
127
127
|
end
|
|
128
|
+
|
|
129
|
+
# Terminate the Pub/Sub subscriber + async metrics thread on process exit.
|
|
130
|
+
# Without this, Ruby waits on the blocking `Redis#subscribe` call inside
|
|
131
|
+
# the subscriber thread and Puma/Rails shutdown stalls.
|
|
132
|
+
initializer 'magick.shutdown_hook' do
|
|
133
|
+
at_exit do
|
|
134
|
+
Magick.shutdown!
|
|
135
|
+
rescue StandardError
|
|
136
|
+
# Best-effort: never raise from an at_exit handler.
|
|
137
|
+
end
|
|
138
|
+
end
|
|
128
139
|
end
|
|
129
140
|
end
|
|
130
141
|
|
data/lib/magick/version.rb
CHANGED
data/lib/magick.rb
CHANGED
|
@@ -265,6 +265,15 @@ module Magick
|
|
|
265
265
|
@performance_metrics&.clear!
|
|
266
266
|
end
|
|
267
267
|
|
|
268
|
+
# Gracefully terminate background threads (Redis Pub/Sub subscriber,
|
|
269
|
+
# async metrics processor) so the host process can exit promptly.
|
|
270
|
+
# Intended for use in Rails shutdown hooks, `at_exit`, or tests.
|
|
271
|
+
def shutdown!(timeout: 5)
|
|
272
|
+
safely_shutdown(@adapter_registry) { |r| r.shutdown(timeout: timeout) }
|
|
273
|
+
safely_shutdown(@performance_metrics, &:stop_async_processor)
|
|
274
|
+
true
|
|
275
|
+
end
|
|
276
|
+
|
|
268
277
|
# Get default adapter registry (public method for use by other classes)
|
|
269
278
|
def default_adapter_registry
|
|
270
279
|
@default_adapter_registry ||= begin
|
|
@@ -279,5 +288,15 @@ module Magick
|
|
|
279
288
|
end
|
|
280
289
|
|
|
281
290
|
private
|
|
291
|
+
|
|
292
|
+
# Run a cleanup action on a collaborator, swallowing errors so that
|
|
293
|
+
# shutdown hooks (at_exit, Rails) never raise.
|
|
294
|
+
def safely_shutdown(collaborator)
|
|
295
|
+
return unless collaborator
|
|
296
|
+
|
|
297
|
+
yield(collaborator)
|
|
298
|
+
rescue StandardError
|
|
299
|
+
# Best-effort: termination paths must not raise.
|
|
300
|
+
end
|
|
282
301
|
end
|
|
283
302
|
end
|