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 +4 -4
- data/README.md +71 -7
- data/app/controllers/magick/adminui/features_controller.rb +46 -0
- data/config/magick.rb.example +5 -3
- data/lib/generators/magick/install/templates/magick.rb +5 -3
- data/lib/magick/adapters/registry.rb +138 -55
- data/lib/magick/admin_ui.rb +5 -1
- data/lib/magick/config.rb +10 -4
- data/lib/magick/feature.rb +313 -166
- data/lib/magick/rails/railtie.rb +37 -2
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +205 -51
- data/lib/magick.rb +61 -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: 958764166d59a55b2243c5ce0f009164c26c75ae07740b200808866f3d6ad543
|
|
4
|
+
data.tar.gz: a96a1b187259c225a0b55ec243dbdfc9d9771621b420a4519c2fc7c4e9ee6a5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
-
|
|
539
|
-
Magick.
|
|
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
|
|
908
|
-
automatically
|
|
909
|
-
`
|
|
910
|
-
|
|
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
|
data/config/magick.rb.example
CHANGED
|
@@ -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
|
|
27
|
-
|
|
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 (
|
|
26
|
+
# Enable audit logging (every mutation is logged under its real action name)
|
|
27
27
|
audit_log enabled: true
|
|
28
28
|
|
|
29
|
-
# Enable versioning
|
|
30
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/magick/admin_ui.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|