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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce69954c64fae4d8ec9d10d177621ef508c1f0d8db98b9012849250d90cc974f
4
- data.tar.gz: 41024341850d984dc1b16c8e85913160a94eb92b96f5eaf07477d9ff93a46de4
3
+ metadata.gz: 958764166d59a55b2243c5ce0f009164c26c75ae07740b200808866f3d6ad543
4
+ data.tar.gz: a96a1b187259c225a0b55ec243dbdfc9d9771621b420a4519c2fc7c4e9ee6a5c
5
5
  SHA512:
6
- metadata.gz: a16aa4722424020d4d4ab0d45c1dc5bfec6452ae6f24177d713e6f98790d33b4851bb340e39603aecf52c06bf2d669d0876c0b3835bc9ebf4f63cd92e7776ccc
7
- data.tar.gz: 3a49561daaf30a060abefd7c11413dd5e87e27771b59e089d287cb2788920f5f9c334c4922e064d03c18ce692b6f37a6bfeaf419f09ba12891313c3be1eb1f8a
6
+ metadata.gz: 1affc66448028865d889535959d7970e8b54d1b3e3367d0bd4fb19ec8b18451c9bbc4714735e5f98f26da4a124e52b56a00c345d161660bfb944a9adb2bcbb12
7
+ data.tar.gz: 3ef2b6d8341517ef3bc8099a582e550877242d50f3fb67eab645b7d13f7adbdcf999f9f7a1d39e86ed3f5d1c13a98e8a69200dc5ab3b8c666cd14e924c462d0f
data/README.md CHANGED
@@ -531,12 +531,58 @@ Magick.import(File.read('features.json'))
531
531
 
532
532
  #### Versioning and Rollback
533
533
 
534
+ Every state-changing operation (value, status, group, targeting, exclusions,
535
+ variants, dependencies, delete) automatically records a version snapshot and
536
+ an audit entry — one per logical operation, under its real action name
537
+ (`enable`, `exclude_user`, `set_status`, …). Nested internals never
538
+ double-record.
539
+
534
540
  ```ruby
535
- # Save current state as version
541
+ # History accumulates automatically:
542
+ Magick[:my_feature].enable # => version 1 (action: "enable")
543
+ Magick[:my_feature].enable_for_user(42) # => version 2 (action: "enable_for_user")
544
+
545
+ # Inspect history (hot window: last 50 versions by default)
546
+ Magick.versioning.get_versions(:my_feature)
547
+
548
+ # Include the unlimited ActiveRecord archive (when AR adapter is configured)
549
+ Magick.versioning.get_versions(:my_feature, all: true)
550
+
551
+ # Rollback fully restores a snapshot: value (including false/empty), status,
552
+ # group, and the entire targeting hash — and records the rollback itself as a
553
+ # new version, so history only ever rolls forward.
554
+ Magick.versioning.rollback(:my_feature, 2)
555
+
556
+ # Manual snapshots still work (action: "manual")
536
557
  Magick.versioning.save_version(:my_feature, created_by: current_user.id)
558
+ ```
559
+
560
+ **Retention is tiered:** memory/Redis keep the last `max_versions` snapshots
561
+ (default 50) for fast access; the ActiveRecord adapter keeps an unlimited
562
+ archive that also survives feature deletion.
537
563
 
538
- # Rollback to previous version
539
- Magick.versioning.rollback(:my_feature, version: 2)
564
+ ```ruby
565
+ Magick.configure do
566
+ versioning enabled: true, max_versions: 50
567
+ end
568
+ ```
569
+
570
+ **Attribution:** wrap changes in `Magick.with_actor` to stamp audit entries
571
+ (`user_id`) and versions (`created_by`):
572
+
573
+ ```ruby
574
+ Magick.with_actor(current_user.id) do
575
+ Magick[:my_feature].enable_for_user(42)
576
+ end
577
+ ```
578
+
579
+ **Boot replay is not recorded:** the Rails railtie loads `config/features.rb`
580
+ inside `Magick.definition_mode`, so re-applying declarative definitions on
581
+ every process boot does not flood history. Non-Rails apps should wrap their
582
+ own definition file load the same way:
583
+
584
+ ```ruby
585
+ Magick.definition_mode { load 'config/features.rb' }
540
586
  ```
541
587
 
542
588
  #### Performance Metrics
@@ -595,6 +641,11 @@ end
595
641
 
596
642
  #### Audit Logging
597
643
 
644
+ Every mutation is logged under its real action name (`enable`, `disable`,
645
+ `set_value`, `enable_for_user`, `exclude_role`, `set_status`, `set_group`,
646
+ `delete`, `rollback`, …). One logical operation produces exactly one entry:
647
+ `enable` no longer surfaces as a bare `set_value`.
648
+
598
649
  ```ruby
599
650
  # View audit log entries
600
651
  entries = Magick.audit_log.entries(feature_name: :my_feature, limit: 100)
@@ -603,6 +654,15 @@ entries.each do |entry|
603
654
  end
604
655
  ```
605
656
 
657
+ In the Admin UI, configure a `current_actor` hook so every change made
658
+ through the UI is attributed:
659
+
660
+ ```ruby
661
+ Magick::AdminUI.configure do |config|
662
+ config.current_actor = ->(controller) { controller.session[:admin_id] }
663
+ end
664
+ ```
665
+
606
666
  ## Architecture
607
667
 
608
668
  ### Adapters
@@ -904,10 +964,14 @@ Magick.shutdown! # default 5 second join timeout
904
964
  Magick.shutdown!(timeout: 1) # more aggressive
905
965
  ```
906
966
 
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.
967
+ Fork-based deployments (Puma workers with `preload_app!`, Unicorn) are handled
968
+ automatically. A Rack middleware (`Magick::Rails::SubscriberMiddleware`) calls
969
+ `ensure_subscriber!` on each request a pid-guarded no-op once the subscriber
970
+ is running so a worker that inherited a dead parent thread starts its own
971
+ subscriber on its first request. This matters because in production
972
+ `config.to_prepare` runs **once at boot** (before workers fork), not per
973
+ request, so it cannot revive the subscriber inside forked workers on its own.
974
+ No action required from the host app.
911
975
 
912
976
  ## Admin UI Security
913
977
 
@@ -15,6 +15,16 @@ 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
+ # Attribute every change made during the request to the configured
19
+ # actor, so audit entries and version snapshots record who did it.
20
+ around_action :with_magick_actor
21
+ # Render the TRUE current state, not this process's local cache. In a
22
+ # multi-process / multi-container deployment the enable/disable POST and
23
+ # the redirected GET are load-balanced to different processes, so the
24
+ # process rendering the page may hold a stale memory copy until Pub/Sub
25
+ # catches up. These refresh from the shared backend before rendering.
26
+ before_action :refresh_all_features_from_source, only: %i[index]
27
+ before_action :refresh_feature_from_source, only: %i[show edit]
18
28
 
19
29
  # Make route helpers available in views via magick_admin_ui helper
20
30
  helper_method :magick_admin_ui, :available_roles, :available_tags, :partially_enabled?
@@ -314,6 +324,22 @@ module Magick
314
324
 
315
325
  private
316
326
 
327
+ # Resolve the acting admin via the configurable AdminUI hook and run the
328
+ # action inside Magick.with_actor. A failing resolver only costs
329
+ # attribution — it must never 500 the admin UI, and it is rescued
330
+ # separately so an action error is never swallowed or re-run.
331
+ def with_magick_actor(&block)
332
+ actor = begin
333
+ resolver = Magick::AdminUI.config.current_actor
334
+ resolver.respond_to?(:call) ? resolver.call(self) : nil
335
+ rescue StandardError => e
336
+ Rails.logger.warn "Magick: current_actor hook failed: #{e.class}: #{e.message}" if defined?(Rails)
337
+ nil
338
+ end
339
+
340
+ actor ? Magick.with_actor(actor, &block) : yield
341
+ end
342
+
317
343
  def authenticate_admin!
318
344
  return unless Magick::AdminUI.config.require_role
319
345
 
@@ -334,6 +360,26 @@ module Magick
334
360
  obj.is_a?(Hash) || (defined?(ActionController::Parameters) && obj.is_a?(ActionController::Parameters))
335
361
  end
336
362
 
363
+ # Refresh every registered feature from the shared backend so the index
364
+ # reflects authoritative state regardless of which container serves it.
365
+ # Best-effort: a backend hiccup must never 500 the admin list.
366
+ def refresh_all_features_from_source
367
+ registry = Magick.adapter_registry
368
+ registry.refresh_all_from_source if registry.respond_to?(:refresh_all_from_source)
369
+ Magick.features.each_value { |f| f.reload if f.respond_to?(:reload) }
370
+ rescue StandardError => e
371
+ Rails.logger.warn "Magick: admin source refresh failed: #{e.class}: #{e.message}" if defined?(Rails)
372
+ end
373
+
374
+ # Refresh the single feature being viewed/edited from the shared backend.
375
+ def refresh_feature_from_source
376
+ return unless @feature
377
+
378
+ @feature.reload_from_source! if @feature.respond_to?(:reload_from_source!)
379
+ rescue StandardError => e
380
+ Rails.logger.warn "Magick: admin source refresh failed: #{e.class}: #{e.message}" if defined?(Rails)
381
+ end
382
+
337
383
  def set_feature
338
384
  feature_name = params[:id].to_s
339
385
  # Do NOT fall back to Magick[feature_name] — that would lazily create
@@ -20,11 +20,13 @@ Magick.configure do
20
20
  # Enable performance metrics tracking
21
21
  performance_metrics enabled: true
22
22
 
23
- # Enable audit logging
23
+ # Enable audit logging (every mutation is logged under its real action name)
24
24
  audit_log enabled: true
25
25
 
26
- # Enable versioning support
27
- versioning enabled: true
26
+ # Enable versioning (every save creates a version snapshot; allows rollback)
27
+ # max_versions caps the hot window kept in memory/Redis; the ActiveRecord
28
+ # adapter keeps an unlimited archive.
29
+ versioning enabled: true, max_versions: 50
28
30
 
29
31
  # Enable deprecation warnings
30
32
  warn_on_deprecated true
@@ -23,11 +23,13 @@ Magick.configure do
23
23
  # Enable performance metrics tracking
24
24
  performance_metrics enabled: true
25
25
 
26
- # Enable audit logging (tracks who changed what, when)
26
+ # Enable audit logging (every mutation is logged under its real action name)
27
27
  audit_log enabled: true
28
28
 
29
- # Enable versioning support (allows rollback)
30
- versioning enabled: true
29
+ # Enable versioning (every save creates a version snapshot; allows rollback)
30
+ # max_versions caps the hot window kept in memory/Redis; the ActiveRecord
31
+ # adapter keeps an unlimited archive.
32
+ versioning enabled: true, max_versions: 50
31
33
 
32
34
  # Enable deprecation warnings in logs
33
35
  warn_on_deprecated enabled: true
@@ -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
@@ -177,7 +176,9 @@ module Magick
177
176
  features += memory_adapter.all_features if memory_adapter
178
177
  features += redis_adapter.all_features if redis_adapter
179
178
  features += active_record_adapter.all_features if active_record_adapter
180
- features.uniq
179
+ # Version history is stored under a reserved pseudo-feature namespace;
180
+ # it is bookkeeping, not a feature.
181
+ features.uniq.reject { |f| f.to_s.start_with?(Versioning::STORE_PREFIX) }
181
182
  end
182
183
 
183
184
  # Load all keys for a single feature in one call instead of N separate get() calls
@@ -217,6 +218,39 @@ module Magick
217
218
  {}
218
219
  end
219
220
 
221
+ # Read a feature's complete data straight from the shared, authoritative
222
+ # backend — ActiveRecord first (it is written synchronously on every
223
+ # set/set_all_data), then Redis — bypassing this process's local memory
224
+ # cache, and refresh memory with the result.
225
+ #
226
+ # The Admin UI uses this so a toggle is reflected immediately on whichever
227
+ # process/container serves the (load-balanced) request after the write,
228
+ # instead of rendering this process's possibly-stale memory cache while it
229
+ # waits for Pub/Sub invalidation to arrive.
230
+ def authoritative_get_all_data(feature_name)
231
+ data = read_from_source(feature_name)
232
+ if data && !data.empty?
233
+ memory_adapter&.set_all_data(feature_name, data)
234
+ return data
235
+ end
236
+
237
+ # Source unavailable (Redis/AR down, or feature absent there) — fall back
238
+ # to whatever this process already has rather than wiping a usable cache.
239
+ memory_adapter ? memory_adapter.get_all_data(feature_name) : {}
240
+ end
241
+
242
+ # Bulk variant of #authoritative_get_all_data: refresh the local memory
243
+ # cache for EVERY feature from the shared backend in 1-2 queries. Returns
244
+ # the loaded data. Used by the Admin UI index so the full list reflects
245
+ # authoritative state regardless of which container serves it.
246
+ def refresh_all_from_source
247
+ data = load_all_from_source
248
+ if memory_adapter && !data.empty?
249
+ data.each { |feature_name, feature_data| memory_adapter.set_all_data(feature_name, feature_data) }
250
+ end
251
+ data
252
+ end
253
+
220
254
  # Bulk load ALL features into memory cache in minimal queries.
221
255
  # Call this after configuration to warm the cache.
222
256
  def preload!
@@ -325,9 +359,13 @@ module Magick
325
359
  end
326
360
  end
327
361
 
362
+ # Public so Versioning can apply tiered retention: hot window written to
363
+ # memory/Redis, unlimited archive written to ActiveRecord only.
364
+ attr_reader :memory_adapter, :redis_adapter, :active_record_adapter
365
+
328
366
  private
329
367
 
330
- attr_reader :memory_adapter, :redis_adapter, :active_record_adapter, :circuit_breaker
368
+ attr_reader :circuit_breaker
331
369
 
332
370
  # Signal the subscribe loop to return, then close the connection so any
333
371
  # retry/reconnect attempt fails fast instead of sleeping for 5s.
@@ -371,6 +409,101 @@ module Magick
371
409
  thread
372
410
  end
373
411
 
412
+ # Handle one cache-invalidation message: refresh this process's view of
413
+ # the feature from the shared backend. Returns true when the message was
414
+ # acted on, false when it was rejected/skipped (used by specs).
415
+ #
416
+ # Every VALID, non-self message triggers a full-state reload. We do NOT
417
+ # debounce by a time window: a single enable/disable emits two publishes
418
+ # (targeting then value), and dropping the second would leave this process
419
+ # holding the old value until its memory TTL expires. Each reload reads the
420
+ # feature's COMPLETE current state, so processing every message is
421
+ # idempotent, and feature-flag writes are admin-rate — redundant reloads
422
+ # are cheap and rare.
423
+ def process_cache_invalidation(feature_name)
424
+ feature_name_str = feature_name.to_s
425
+
426
+ # Reject malformed payloads before doing anything with them. A shared
427
+ # Redis DB is not a trust boundary — reject anything that isn't a
428
+ # plausible feature identifier.
429
+ unless FEATURE_NAME_PATTERN.match?(feature_name_str)
430
+ warn "Magick: ignoring malformed pubsub payload (#{feature_name_str.bytesize}B)" if rails_development?
431
+ return false
432
+ end
433
+
434
+ # Skip self-invalidation: if this process just wrote this feature, memory
435
+ # already has the correct value. Reloading would revert it to stale data
436
+ # (especially with async writes that publish after the Redis write).
437
+ if local_write?(feature_name_str)
438
+ Rails.logger.debug "Magick: Skipping self-invalidation for '#{feature_name_str}'" if rails_development?
439
+ return false
440
+ end
441
+
442
+ # Invalidate the local memory cache, then reload the registered feature
443
+ # instance from the shared backend (the publisher writes Redis/AR BEFORE
444
+ # publishing, so fresh data is available by now).
445
+ memory_adapter&.delete(feature_name_str)
446
+ if defined?(Magick) && Magick.respond_to?(:features) && Magick.features.key?(feature_name_str)
447
+ feature = Magick.features[feature_name_str]
448
+ if feature.respond_to?(:reload)
449
+ feature.reload
450
+ Rails.logger.debug "Magick: Reloaded '#{feature_name_str}' after cache invalidation" if rails_development?
451
+ end
452
+ end
453
+ true
454
+ end
455
+
456
+ def rails_development?
457
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
458
+ end
459
+
460
+ # Read a feature's full data from the shared, authoritative backend,
461
+ # skipping the local memory cache. ActiveRecord is preferred because it is
462
+ # written synchronously on every set (Redis may lag under async_updates).
463
+ def read_from_source(feature_name)
464
+ if active_record_adapter
465
+ begin
466
+ data = active_record_adapter.get_all_data(feature_name)
467
+ return data if data && !data.empty?
468
+ rescue StandardError, AdapterError
469
+ # fall through to Redis
470
+ end
471
+ end
472
+
473
+ if redis_adapter
474
+ begin
475
+ return circuit_breaker.call { redis_adapter.get_all_data(feature_name) }
476
+ rescue StandardError, AdapterError
477
+ nil
478
+ end
479
+ end
480
+
481
+ nil
482
+ end
483
+
484
+ # Bulk read ALL features from the shared, authoritative backend, skipping
485
+ # the local memory cache. AR preferred (synchronous), Redis fallback.
486
+ def load_all_from_source
487
+ if active_record_adapter
488
+ begin
489
+ data = active_record_adapter.load_all_features_data
490
+ return data if data && !data.empty?
491
+ rescue StandardError, AdapterError
492
+ # fall through to Redis
493
+ end
494
+ end
495
+
496
+ if redis_adapter
497
+ begin
498
+ return circuit_breaker.call { redis_adapter.load_all_features_data } || {}
499
+ rescue StandardError, AdapterError
500
+ {}
501
+ end
502
+ end
503
+
504
+ {}
505
+ end
506
+
374
507
  # Record that this process just wrote a feature, so the subscriber
375
508
  # ignores its own Pub/Sub messages and doesn't revert the correct in-memory state.
376
509
  def record_local_write(feature_name)
@@ -378,7 +511,7 @@ module Magick
378
511
  @local_writes[feature_name.to_s] = Time.now.to_f
379
512
  # Also sweep stale tracking entries on the write path — a write-heavy
380
513
  # process that rarely reads would otherwise never trigger cleanup,
381
- # letting @local_writes and @last_reload_times grow unboundedly.
514
+ # letting @local_writes grow unboundedly.
382
515
  cleanup_stale_tracking_entries
383
516
  end
384
517
  end
@@ -408,7 +541,6 @@ module Magick
408
541
 
409
542
  @last_tracking_cleanup = now
410
543
  @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
544
  end
413
545
 
414
546
  # Start a background thread to listen for cache invalidation messages
@@ -442,56 +574,7 @@ module Magick
442
574
 
443
575
  @subscriber.subscribe(CACHE_INVALIDATION_CHANNEL) do |on|
444
576
  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
577
+ process_cache_invalidation(feature_name)
495
578
  rescue StandardError => e
496
579
  # Log error but don't crash the subscriber thread
497
580
  # Skip logging RSpec mock errors in test environments
@@ -17,7 +17,7 @@ module Magick
17
17
  end
18
18
 
19
19
  class Configuration
20
- attr_accessor :theme, :brand_name, :require_role, :available_roles, :available_tags
20
+ attr_accessor :theme, :brand_name, :require_role, :available_roles, :available_tags, :current_actor
21
21
 
22
22
  def initialize
23
23
  @theme = :light
@@ -25,6 +25,10 @@ module Magick
25
25
  @require_role = nil
26
26
  @available_roles = [] # Can be populated via DSL: admin_ui { roles ['admin', 'user', 'manager'] }
27
27
  @available_tags = nil # Can be array or lambda: -> { Tag.all }
28
+ # Lambda receiving the controller, returning who is making the
29
+ # change; stamped onto audit entries (user_id) and versions
30
+ # (created_by): -> (controller) { controller.current_user&.id }
31
+ @current_actor = nil
28
32
  end
29
33
 
30
34
  # Get available tags, calling lambda if needed
data/lib/magick/config.rb CHANGED
@@ -135,8 +135,11 @@ module Magick
135
135
  end
136
136
  end
137
137
 
138
- def versioning(enabled: true)
139
- @versioning = (Versioning.new(adapter_registry || default_adapter_registry) if enabled)
138
+ def versioning(enabled: true, max_versions: Versioning::DEFAULT_MAX_VERSIONS)
139
+ @versioning_enabled = enabled
140
+ @versioning = if enabled
141
+ Versioning.new(adapter_registry || default_adapter_registry, max_versions: max_versions)
142
+ end
140
143
  end
141
144
 
142
145
  def circuit_breaker(threshold: nil, timeout: nil)
@@ -186,8 +189,11 @@ module Magick
186
189
  end
187
190
  end
188
191
 
189
- Magick.audit_log = audit_log if audit_log
190
- Magick.versioning = versioning if versioning
192
+ # Read the ivars directly: calling the DSL methods here would re-run
193
+ # them with their defaults and stomp explicit `enabled: false` settings.
194
+ Magick.audit_log = @audit_log if @audit_log
195
+ Magick.versioning = @versioning if @versioning
196
+ Magick.versioning_enabled = @versioning_enabled unless @versioning_enabled.nil?
191
197
  Magick.warn_on_deprecated = warn_on_deprecated
192
198
  end
193
199