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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce69954c64fae4d8ec9d10d177621ef508c1f0d8db98b9012849250d90cc974f
4
- data.tar.gz: 41024341850d984dc1b16c8e85913160a94eb92b96f5eaf07477d9ff93a46de4
3
+ metadata.gz: 3e6f3b4ee81b2521e7f6975c0bd3c7a0a886ea2e23b5792b37e48cd28b08cb8d
4
+ data.tar.gz: 3aeb62560e637d844af0ea4e320a08a48eb72a33ebfd0653482899f42e8e2079
5
5
  SHA512:
6
- metadata.gz: a16aa4722424020d4d4ab0d45c1dc5bfec6452ae6f24177d713e6f98790d33b4851bb340e39603aecf52c06bf2d669d0876c0b3835bc9ebf4f63cd92e7776ccc
7
- data.tar.gz: 3a49561daaf30a060abefd7c11413dd5e87e27771b59e089d287cb2788920f5f9c334c4922e064d03c18ce692b6f37a6bfeaf419f09ba12891313c3be1eb1f8a
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`, Unicorn) are handled
908
- automatically: `config.to_prepare` calls `ensure_subscriber!` and
909
- `ensure_async_processor!` on every prepare cycle, so children that inherit
910
- stale parent threads start fresh. No action required from the host app.
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 and @last_reload_times grow unboundedly.
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
- feature_name_str = feature_name.to_s
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
@@ -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
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '1.4.2'
4
+ VERSION = '1.4.3'
5
5
  end
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.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov