magick-feature-flags 1.4.2 → 1.4.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 +4 -4
- data/README.md +8 -4
- data/app/controllers/magick/adminui/features_controller.rb +27 -0
- data/lib/magick/adapters/registry.rb +130 -53
- data/lib/magick/feature.rb +9 -0
- data/lib/magick/rails/railtie.rb +32 -0
- data/lib/magick/version.rb +1 -1
- 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: 3e6f3b4ee81b2521e7f6975c0bd3c7a0a886ea2e23b5792b37e48cd28b08cb8d
|
|
4
|
+
data.tar.gz: 3aeb62560e637d844af0ea4e320a08a48eb72a33ebfd0653482899f42e8e2079
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1135ba02b878e0d772259545231aa89f950e11199a0ee8d982440b9040f9c575a077567f3cd404727b27eb6eb962d811c6b301f92ad2227817289851392cf6c9
|
|
7
|
+
data.tar.gz: fb95eca4f8aa3b013298c087209818c91338ef4f9fe2a843951777df4c17fbe9bf83fdc5b1b45447503d5e718c6059d8a57b888746a36f463c929d8965526b3a
|
data/README.md
CHANGED
|
@@ -904,10 +904,14 @@ Magick.shutdown! # default 5 second join timeout
|
|
|
904
904
|
Magick.shutdown!(timeout: 1) # more aggressive
|
|
905
905
|
```
|
|
906
906
|
|
|
907
|
-
Fork-based deployments (Puma workers with `preload_app
|
|
908
|
-
automatically
|
|
909
|
-
`
|
|
910
|
-
|
|
907
|
+
Fork-based deployments (Puma workers with `preload_app!`, Unicorn) are handled
|
|
908
|
+
automatically. A Rack middleware (`Magick::Rails::SubscriberMiddleware`) calls
|
|
909
|
+
`ensure_subscriber!` on each request — a pid-guarded no-op once the subscriber
|
|
910
|
+
is running — so a worker that inherited a dead parent thread starts its own
|
|
911
|
+
subscriber on its first request. This matters because in production
|
|
912
|
+
`config.to_prepare` runs **once at boot** (before workers fork), not per
|
|
913
|
+
request, so it cannot revive the subscriber inside forked workers on its own.
|
|
914
|
+
No action required from the host app.
|
|
911
915
|
|
|
912
916
|
## Admin UI Security
|
|
913
917
|
|
|
@@ -15,6 +15,13 @@ module Magick
|
|
|
15
15
|
layout 'application'
|
|
16
16
|
before_action :authenticate_admin!
|
|
17
17
|
before_action :set_feature, only: %i[show edit update enable disable enable_for_user enable_for_role disable_for_role update_targeting update_variants]
|
|
18
|
+
# Render the TRUE current state, not this process's local cache. In a
|
|
19
|
+
# multi-process / multi-container deployment the enable/disable POST and
|
|
20
|
+
# the redirected GET are load-balanced to different processes, so the
|
|
21
|
+
# process rendering the page may hold a stale memory copy until Pub/Sub
|
|
22
|
+
# catches up. These refresh from the shared backend before rendering.
|
|
23
|
+
before_action :refresh_all_features_from_source, only: %i[index]
|
|
24
|
+
before_action :refresh_feature_from_source, only: %i[show edit]
|
|
18
25
|
|
|
19
26
|
# Make route helpers available in views via magick_admin_ui helper
|
|
20
27
|
helper_method :magick_admin_ui, :available_roles, :available_tags, :partially_enabled?
|
|
@@ -334,6 +341,26 @@ module Magick
|
|
|
334
341
|
obj.is_a?(Hash) || (defined?(ActionController::Parameters) && obj.is_a?(ActionController::Parameters))
|
|
335
342
|
end
|
|
336
343
|
|
|
344
|
+
# Refresh every registered feature from the shared backend so the index
|
|
345
|
+
# reflects authoritative state regardless of which container serves it.
|
|
346
|
+
# Best-effort: a backend hiccup must never 500 the admin list.
|
|
347
|
+
def refresh_all_features_from_source
|
|
348
|
+
registry = Magick.adapter_registry
|
|
349
|
+
registry.refresh_all_from_source if registry.respond_to?(:refresh_all_from_source)
|
|
350
|
+
Magick.features.each_value { |f| f.reload if f.respond_to?(:reload) }
|
|
351
|
+
rescue StandardError => e
|
|
352
|
+
Rails.logger.warn "Magick: admin source refresh failed: #{e.class}: #{e.message}" if defined?(Rails)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Refresh the single feature being viewed/edited from the shared backend.
|
|
356
|
+
def refresh_feature_from_source
|
|
357
|
+
return unless @feature
|
|
358
|
+
|
|
359
|
+
@feature.reload_from_source! if @feature.respond_to?(:reload_from_source!)
|
|
360
|
+
rescue StandardError => e
|
|
361
|
+
Rails.logger.warn "Magick: admin source refresh failed: #{e.class}: #{e.message}" if defined?(Rails)
|
|
362
|
+
end
|
|
363
|
+
|
|
337
364
|
def set_feature
|
|
338
365
|
feature_name = params[:id].to_s
|
|
339
366
|
# Do NOT fall back to Magick[feature_name] — that would lazily create
|
|
@@ -24,7 +24,6 @@ module Magick
|
|
|
24
24
|
@subscriber_thread = nil
|
|
25
25
|
@subscriber = nil
|
|
26
26
|
@refresh_thread = nil
|
|
27
|
-
@last_reload_times = {} # Track last reload time per feature for debouncing
|
|
28
27
|
@local_writes = {} # Track recent local writes to skip self-invalidation
|
|
29
28
|
@reload_mutex = Mutex.new
|
|
30
29
|
@stopping = false
|
|
@@ -217,6 +216,39 @@ module Magick
|
|
|
217
216
|
{}
|
|
218
217
|
end
|
|
219
218
|
|
|
219
|
+
# Read a feature's complete data straight from the shared, authoritative
|
|
220
|
+
# backend — ActiveRecord first (it is written synchronously on every
|
|
221
|
+
# set/set_all_data), then Redis — bypassing this process's local memory
|
|
222
|
+
# cache, and refresh memory with the result.
|
|
223
|
+
#
|
|
224
|
+
# The Admin UI uses this so a toggle is reflected immediately on whichever
|
|
225
|
+
# process/container serves the (load-balanced) request after the write,
|
|
226
|
+
# instead of rendering this process's possibly-stale memory cache while it
|
|
227
|
+
# waits for Pub/Sub invalidation to arrive.
|
|
228
|
+
def authoritative_get_all_data(feature_name)
|
|
229
|
+
data = read_from_source(feature_name)
|
|
230
|
+
if data && !data.empty?
|
|
231
|
+
memory_adapter&.set_all_data(feature_name, data)
|
|
232
|
+
return data
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Source unavailable (Redis/AR down, or feature absent there) — fall back
|
|
236
|
+
# to whatever this process already has rather than wiping a usable cache.
|
|
237
|
+
memory_adapter ? memory_adapter.get_all_data(feature_name) : {}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Bulk variant of #authoritative_get_all_data: refresh the local memory
|
|
241
|
+
# cache for EVERY feature from the shared backend in 1-2 queries. Returns
|
|
242
|
+
# the loaded data. Used by the Admin UI index so the full list reflects
|
|
243
|
+
# authoritative state regardless of which container serves it.
|
|
244
|
+
def refresh_all_from_source
|
|
245
|
+
data = load_all_from_source
|
|
246
|
+
if memory_adapter && !data.empty?
|
|
247
|
+
data.each { |feature_name, feature_data| memory_adapter.set_all_data(feature_name, feature_data) }
|
|
248
|
+
end
|
|
249
|
+
data
|
|
250
|
+
end
|
|
251
|
+
|
|
220
252
|
# Bulk load ALL features into memory cache in minimal queries.
|
|
221
253
|
# Call this after configuration to warm the cache.
|
|
222
254
|
def preload!
|
|
@@ -371,6 +403,101 @@ module Magick
|
|
|
371
403
|
thread
|
|
372
404
|
end
|
|
373
405
|
|
|
406
|
+
# Handle one cache-invalidation message: refresh this process's view of
|
|
407
|
+
# the feature from the shared backend. Returns true when the message was
|
|
408
|
+
# acted on, false when it was rejected/skipped (used by specs).
|
|
409
|
+
#
|
|
410
|
+
# Every VALID, non-self message triggers a full-state reload. We do NOT
|
|
411
|
+
# debounce by a time window: a single enable/disable emits two publishes
|
|
412
|
+
# (targeting then value), and dropping the second would leave this process
|
|
413
|
+
# holding the old value until its memory TTL expires. Each reload reads the
|
|
414
|
+
# feature's COMPLETE current state, so processing every message is
|
|
415
|
+
# idempotent, and feature-flag writes are admin-rate — redundant reloads
|
|
416
|
+
# are cheap and rare.
|
|
417
|
+
def process_cache_invalidation(feature_name)
|
|
418
|
+
feature_name_str = feature_name.to_s
|
|
419
|
+
|
|
420
|
+
# Reject malformed payloads before doing anything with them. A shared
|
|
421
|
+
# Redis DB is not a trust boundary — reject anything that isn't a
|
|
422
|
+
# plausible feature identifier.
|
|
423
|
+
unless FEATURE_NAME_PATTERN.match?(feature_name_str)
|
|
424
|
+
warn "Magick: ignoring malformed pubsub payload (#{feature_name_str.bytesize}B)" if rails_development?
|
|
425
|
+
return false
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Skip self-invalidation: if this process just wrote this feature, memory
|
|
429
|
+
# already has the correct value. Reloading would revert it to stale data
|
|
430
|
+
# (especially with async writes that publish after the Redis write).
|
|
431
|
+
if local_write?(feature_name_str)
|
|
432
|
+
Rails.logger.debug "Magick: Skipping self-invalidation for '#{feature_name_str}'" if rails_development?
|
|
433
|
+
return false
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Invalidate the local memory cache, then reload the registered feature
|
|
437
|
+
# instance from the shared backend (the publisher writes Redis/AR BEFORE
|
|
438
|
+
# publishing, so fresh data is available by now).
|
|
439
|
+
memory_adapter&.delete(feature_name_str)
|
|
440
|
+
if defined?(Magick) && Magick.respond_to?(:features) && Magick.features.key?(feature_name_str)
|
|
441
|
+
feature = Magick.features[feature_name_str]
|
|
442
|
+
if feature.respond_to?(:reload)
|
|
443
|
+
feature.reload
|
|
444
|
+
Rails.logger.debug "Magick: Reloaded '#{feature_name_str}' after cache invalidation" if rails_development?
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
true
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def rails_development?
|
|
451
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Read a feature's full data from the shared, authoritative backend,
|
|
455
|
+
# skipping the local memory cache. ActiveRecord is preferred because it is
|
|
456
|
+
# written synchronously on every set (Redis may lag under async_updates).
|
|
457
|
+
def read_from_source(feature_name)
|
|
458
|
+
if active_record_adapter
|
|
459
|
+
begin
|
|
460
|
+
data = active_record_adapter.get_all_data(feature_name)
|
|
461
|
+
return data if data && !data.empty?
|
|
462
|
+
rescue StandardError, AdapterError
|
|
463
|
+
# fall through to Redis
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
if redis_adapter
|
|
468
|
+
begin
|
|
469
|
+
return circuit_breaker.call { redis_adapter.get_all_data(feature_name) }
|
|
470
|
+
rescue StandardError, AdapterError
|
|
471
|
+
nil
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
nil
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Bulk read ALL features from the shared, authoritative backend, skipping
|
|
479
|
+
# the local memory cache. AR preferred (synchronous), Redis fallback.
|
|
480
|
+
def load_all_from_source
|
|
481
|
+
if active_record_adapter
|
|
482
|
+
begin
|
|
483
|
+
data = active_record_adapter.load_all_features_data
|
|
484
|
+
return data if data && !data.empty?
|
|
485
|
+
rescue StandardError, AdapterError
|
|
486
|
+
# fall through to Redis
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
if redis_adapter
|
|
491
|
+
begin
|
|
492
|
+
return circuit_breaker.call { redis_adapter.load_all_features_data } || {}
|
|
493
|
+
rescue StandardError, AdapterError
|
|
494
|
+
{}
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
{}
|
|
499
|
+
end
|
|
500
|
+
|
|
374
501
|
# Record that this process just wrote a feature, so the subscriber
|
|
375
502
|
# ignores its own Pub/Sub messages and doesn't revert the correct in-memory state.
|
|
376
503
|
def record_local_write(feature_name)
|
|
@@ -378,7 +505,7 @@ module Magick
|
|
|
378
505
|
@local_writes[feature_name.to_s] = Time.now.to_f
|
|
379
506
|
# Also sweep stale tracking entries on the write path — a write-heavy
|
|
380
507
|
# process that rarely reads would otherwise never trigger cleanup,
|
|
381
|
-
# letting @local_writes
|
|
508
|
+
# letting @local_writes grow unboundedly.
|
|
382
509
|
cleanup_stale_tracking_entries
|
|
383
510
|
end
|
|
384
511
|
end
|
|
@@ -408,7 +535,6 @@ module Magick
|
|
|
408
535
|
|
|
409
536
|
@last_tracking_cleanup = now
|
|
410
537
|
@local_writes.delete_if { |_, wrote_at| (now - wrote_at) >= LOCAL_WRITE_TTL }
|
|
411
|
-
@last_reload_times.delete_if { |_, reload_at| (now - reload_at) >= 10.0 }
|
|
412
538
|
end
|
|
413
539
|
|
|
414
540
|
# Start a background thread to listen for cache invalidation messages
|
|
@@ -442,56 +568,7 @@ module Magick
|
|
|
442
568
|
|
|
443
569
|
@subscriber.subscribe(CACHE_INVALIDATION_CHANNEL) do |on|
|
|
444
570
|
on.message do |_channel, feature_name|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
# Reject malformed payloads before doing anything with them.
|
|
448
|
-
# A shared Redis DB is not a trust boundary — reject anything
|
|
449
|
-
# that isn't a plausible feature identifier.
|
|
450
|
-
unless FEATURE_NAME_PATTERN.match?(feature_name_str)
|
|
451
|
-
if defined?(Rails) && Rails.env.development?
|
|
452
|
-
warn "Magick: ignoring malformed pubsub payload (#{feature_name_str.bytesize}B)"
|
|
453
|
-
end
|
|
454
|
-
next
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
# Skip self-invalidation: if this process just wrote this feature,
|
|
458
|
-
# memory already has the correct value. Reloading from Redis would
|
|
459
|
-
# revert it to stale data (especially with async writes).
|
|
460
|
-
if local_write?(feature_name_str)
|
|
461
|
-
if defined?(Rails) && Rails.env.development?
|
|
462
|
-
Rails.logger.debug "Magick: Skipping self-invalidation for '#{feature_name_str}'"
|
|
463
|
-
end
|
|
464
|
-
next
|
|
465
|
-
end
|
|
466
|
-
|
|
467
|
-
# Debounce: only reload if we haven't reloaded this feature in the last 100ms
|
|
468
|
-
should_reload = @reload_mutex.synchronize do
|
|
469
|
-
last_reload = @last_reload_times[feature_name_str]
|
|
470
|
-
now = Time.now.to_f
|
|
471
|
-
if last_reload.nil? || (now - last_reload) > 0.1 # 100ms debounce
|
|
472
|
-
@last_reload_times[feature_name_str] = now
|
|
473
|
-
true
|
|
474
|
-
else
|
|
475
|
-
false
|
|
476
|
-
end
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
next unless should_reload
|
|
480
|
-
|
|
481
|
-
# Invalidate memory cache for this feature
|
|
482
|
-
memory_adapter.delete(feature_name_str) if memory_adapter
|
|
483
|
-
|
|
484
|
-
# Reload the feature instance from the adapter (Redis should have fresh data
|
|
485
|
-
# since remote processes publish AFTER their Redis write completes)
|
|
486
|
-
if defined?(Magick) && Magick.features.key?(feature_name_str)
|
|
487
|
-
feature = Magick.features[feature_name_str]
|
|
488
|
-
if feature.respond_to?(:reload)
|
|
489
|
-
feature.reload
|
|
490
|
-
if defined?(Rails) && Rails.env.development?
|
|
491
|
-
Rails.logger.debug "Magick: Reloaded feature '#{feature_name_str}' after cache invalidation"
|
|
492
|
-
end
|
|
493
|
-
end
|
|
494
|
-
end
|
|
571
|
+
process_cache_invalidation(feature_name)
|
|
495
572
|
rescue StandardError => e
|
|
496
573
|
# Log error but don't crash the subscriber thread
|
|
497
574
|
# Skip logging RSpec mock errors in test environments
|
data/lib/magick/feature.rb
CHANGED
|
@@ -624,6 +624,15 @@ module Magick
|
|
|
624
624
|
true
|
|
625
625
|
end
|
|
626
626
|
|
|
627
|
+
# Reload feature state from the shared, authoritative backend (ActiveRecord/
|
|
628
|
+
# Redis), bypassing this process's local in-process memory cache. Used by the
|
|
629
|
+
# Admin UI so a toggle performed on another process/container is reflected
|
|
630
|
+
# immediately, without waiting for Pub/Sub cache invalidation to arrive.
|
|
631
|
+
def reload_from_source!
|
|
632
|
+
adapter_registry.authoritative_get_all_data(name) if adapter_registry.respond_to?(:authoritative_get_all_data)
|
|
633
|
+
reload
|
|
634
|
+
end
|
|
635
|
+
|
|
627
636
|
# Reload feature state from adapter (useful when feature is changed externally)
|
|
628
637
|
def reload
|
|
629
638
|
load_from_adapter
|
data/lib/magick/rails/railtie.rb
CHANGED
|
@@ -132,6 +132,18 @@ if defined?(Rails)
|
|
|
132
132
|
end
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# Revive the Pub/Sub subscriber in forked workers. `config.to_prepare`
|
|
136
|
+
# only re-runs per request in development; in production it runs once at
|
|
137
|
+
# boot, BEFORE Puma forks its workers under `preload_app!`. Those workers
|
|
138
|
+
# inherit a dead subscriber thread and would never receive cross-process
|
|
139
|
+
# cache invalidations. This middleware calls `ensure_subscriber!` (a
|
|
140
|
+
# pid-guarded no-op once the subscriber is running) on each request, so a
|
|
141
|
+
# forked worker starts its own subscriber on its first request. In
|
|
142
|
+
# single-mode Puma (no fork) it is effectively free.
|
|
143
|
+
initializer 'magick.subscriber_middleware' do |app|
|
|
144
|
+
app.middleware.use Magick::Rails::SubscriberMiddleware
|
|
145
|
+
end
|
|
146
|
+
|
|
135
147
|
# Terminate the Pub/Sub subscriber + async metrics thread on process exit.
|
|
136
148
|
# Without this, Ruby waits on the blocking `Redis#subscribe` call inside
|
|
137
149
|
# the subscriber thread and Puma/Rails shutdown stalls.
|
|
@@ -145,6 +157,26 @@ if defined?(Rails)
|
|
|
145
157
|
end
|
|
146
158
|
end
|
|
147
159
|
|
|
160
|
+
# Ensures each process (including Puma workers forked under `preload_app!`)
|
|
161
|
+
# has a live Redis Pub/Sub subscriber for cross-process cache invalidation.
|
|
162
|
+
# `ensure_subscriber!` returns immediately once `@owner_pid == Process.pid`,
|
|
163
|
+
# so the per-request cost is a single pid comparison after the first call.
|
|
164
|
+
class SubscriberMiddleware
|
|
165
|
+
def initialize(app)
|
|
166
|
+
@app = app
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def call(env)
|
|
170
|
+
begin
|
|
171
|
+
registry = Magick.adapter_registry
|
|
172
|
+
registry.ensure_subscriber! if registry.respond_to?(:ensure_subscriber!)
|
|
173
|
+
rescue StandardError
|
|
174
|
+
# Best-effort: never break a request over subscriber bookkeeping.
|
|
175
|
+
end
|
|
176
|
+
@app.call(env)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
148
180
|
# Request store integration
|
|
149
181
|
module RequestStoreIntegration
|
|
150
182
|
def self.included(base)
|
data/lib/magick/version.rb
CHANGED