magick-feature-flags 1.5.0 → 1.7.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +674 -0
  3. data/README.md +580 -35
  4. data/app/controllers/magick/adminui/features_controller.rb +63 -159
  5. data/app/controllers/magick/adminui/stats_controller.rb +5 -0
  6. data/config/magick.rb.example +6 -2
  7. data/lib/generators/magick/active_record/active_record_generator.rb +2 -2
  8. data/lib/generators/magick/install/install_generator.rb +1 -1
  9. data/lib/generators/magick/install/templates/magick.rb +27 -6
  10. data/lib/magick/adapter_failure.rb +111 -0
  11. data/lib/magick/adapters/active_record.rb +129 -24
  12. data/lib/magick/adapters/async_writer.rb +287 -0
  13. data/lib/magick/adapters/base.rb +49 -0
  14. data/lib/magick/adapters/memory.rb +26 -0
  15. data/lib/magick/adapters/redis.rb +100 -22
  16. data/lib/magick/adapters/registry.rb +724 -215
  17. data/lib/magick/admin_ui/authentication.rb +65 -0
  18. data/lib/magick/admin_ui.rb +26 -1
  19. data/lib/magick/audit_log.rb +284 -19
  20. data/lib/magick/bulk_result.rb +73 -0
  21. data/lib/magick/circuit_breaker.rb +55 -11
  22. data/lib/magick/config.rb +167 -27
  23. data/lib/magick/dsl.rb +1 -1
  24. data/lib/magick/errors.rb +16 -0
  25. data/lib/magick/export_import.rb +38 -58
  26. data/lib/magick/feature.rb +361 -57
  27. data/lib/magick/performance_metrics.rb +168 -117
  28. data/lib/magick/rails/event_subscriber.rb +3 -3
  29. data/lib/magick/rails/events.rb +28 -4
  30. data/lib/magick/rails/railtie.rb +45 -69
  31. data/lib/magick/rails.rb +1 -1
  32. data/lib/magick/request_store_integration.rb +69 -0
  33. data/lib/magick/targeting_payload.rb +260 -0
  34. data/lib/magick/version.rb +1 -1
  35. data/lib/magick/versioning.rb +282 -62
  36. data/lib/magick.rb +108 -10
  37. metadata +115 -2
data/CHANGELOG.md ADDED
@@ -0,0 +1,674 @@
1
+ # Changelog
2
+
3
+ All notable changes to `magick-feature-flags` are documented in this file.
4
+
5
+ ## Unreleased
6
+
7
+ ## 1.7.0 — 2026-09-18
8
+
9
+ ### Upgrading
10
+
11
+ - **Toggles no longer need a restart to reach every process.** Two things
12
+ changed for a Rails host: `require 'magick'` now loads the Railtie (so Puma
13
+ cluster-mode workers start their own Pub/Sub subscriber on their first
14
+ request), and every evaluation asks the registry to re-read the shared
15
+ backend once `refresh_interval` (default 30s) has elapsed. Ops tooling that
16
+ restarts after a flag write can stop doing so once the fleet runs 1.7.0.
17
+ - **First refresh after boot converges on ActiveRecord.** Where ActiveRecord
18
+ and Redis disagree for a flag, evaluation follows ActiveRecord — the same
19
+ source the Admin UI already treats as truth. Redis copies written by a pre-JSON
20
+ serializer (`targeting` as a `Marshal` blob) count as a difference and are
21
+ rewritten once, without any flag changing its answer.
22
+ - **Set `refresh_interval false`** in the configuration DSL to keep the
23
+ Pub/Sub-only behaviour.
24
+ - **The Railtie's own initializers now run** in every host that had not
25
+ required it explicitly: a default registry is built before your initializer
26
+ (and retired when yours replaces it), `config/features.rb` is loaded in
27
+ `Magick.definition_mode`, the cache is preloaded after initialization, and
28
+ `Magick.shutdown!` runs at exit. `config/initializers/features.rb` is left
29
+ to Rails (the Railtie used to load it a second time); wrap definitions kept
30
+ there in `Magick.definition_mode { }` yourself if you want boot replays kept
31
+ out of the audit log.
32
+ - In the configuration DSL, list `redis`/`active_record` before the options
33
+ that shape the registry (`async_updates`, `memory_ttl`, `circuit_breaker`):
34
+ the registry is built at the first adapter call, and options given after it
35
+ do not apply to it. `refresh_interval` is applied afterwards and is
36
+ order-independent.
37
+
38
+ ### Security
39
+
40
+ - **Config**: `ConfigDSL.load_from_file` now checks containment against the
41
+ project root (`Rails.root`, else the directory holding the `Gemfile`, else an
42
+ explicit `ConfigDSL.project_root =`) instead of `Dir.pwd`, and compares
43
+ separator-aware. The old bare-prefix-of-CWD check was bypassable two ways:
44
+ a sibling directory whose name merely starts with the project directory's
45
+ name (`/srv/app-evil` passed for `/srv/app`), and any process running from
46
+ `/`, which made every absolute path pass. Both sat directly in front of an
47
+ `instance_eval` sink. `MAGICK_ALLOW_CONFIG_EVAL=1` still skips the check for
48
+ a trusted file outside the tree, and is still dangerous.
49
+
50
+ - **Admin UI**: `Magick::AdminUI.config.require_role` gates every Admin UI
51
+ route. The hook was wired into the features controller only, so a host
52
+ relying on it alone had its stats route (`GET /magick/stats/:id`) left open —
53
+ and that route distinguishes known from unknown feature names, so it doubled
54
+ as an enumeration oracle for flag names. Both controllers now include the
55
+ shared `Magick::AdminUI::Authentication` filter, and the specs assert
56
+ enforcement across the engine's whole route set rather than one route at a
57
+ time.
58
+ Version history is no longer numbered per process. Fixes a 1.5.0 regression in
59
+ which two containers over one shared backend destroyed each other's snapshots
60
+ and disagreed about what a version number contained. Version snapshots also
61
+ stop leaking into the paths that only ever want features, and appending to the
62
+ archive stops rewriting everything already in it.
63
+
64
+ ### Fixes
65
+
66
+ - **A flag change reaches every process without a restart, even when its
67
+ Pub/Sub invalidation never arrives.** A registered feature caches its value
68
+ and targeting in the object, and the invalidation message was the only thing
69
+ that ever reloaded it: a write made outside a gem process (ops tool, script,
70
+ direct store edit), a Redis user without pub/sub permission, a proxy that
71
+ drops SUBSCRIBE, or a subscriber connection silently killed by a NAT/LB left
72
+ every other process serving the old value until redeploy — which is exactly
73
+ what operators reported. Every evaluation now asks the registry to re-read
74
+ the shared backend once `refresh_interval` (default 30s) has elapsed; one
75
+ caller does a single bulk read and reloads the features whose stored state
76
+ changed since the previous read. Memory is never the comparison, so a local
77
+ write still in flight is not reverted; features missing from the source are
78
+ not evicted. Configure with `refresh_interval 30` in the DSL or
79
+ `registry.refresh_interval = 30`; `false` restores the Pub/Sub-only
80
+ behavior. `Magick.refresh!` forces a read now. (`Registry#refresh_if_stale!`,
81
+ `#refresh_from_source!`; see `docs/adr/0002`.)
82
+
83
+ - **The Railtie is loaded by `require 'magick'`.** It never was: only an
84
+ explicit `require 'magick/rails'`, which nothing documented, loaded
85
+ `Magick::Rails::Railtie`. Every host using the documented
86
+ `gem 'magick-feature-flags', require: 'magick'` therefore ran without the
87
+ fork-aware `SubscriberMiddleware`, the boot-time `preload!` and the `at_exit`
88
+ shutdown — and under Puma `preload_app!` its forked workers inherited a dead
89
+ subscriber thread that nothing ever restarted, so a toggle reached only the
90
+ worker that served the write. The Railtie had also never been booted: its
91
+ middleware class sat one namespace up (`Magick::SubscriberMiddleware`), so
92
+ the first boot raised `NameError`, and every bare `Rails.` inside
93
+ `module Magick` resolved to the gem's own `Magick::Rails` namespace once
94
+ that was loaded. All references now spell `::Rails`, a spec boots a real app
95
+ with the Railtie in a child process, and a static spec keeps the bare
96
+ references out.
97
+
98
+ - **A forked child no longer ends its parent's subscription.** After a fork
99
+ the child holds the parent's subscriber connection object and shares its
100
+ socket; `Registry#shutdown` from the child (an `at_exit` in a worker that
101
+ never served a request) sent UNSUBSCRIBE over the parent's connection and
102
+ silently killed the master's listener. A connection opened by another
103
+ process is now dropped, never closed.
104
+
105
+ - **Reconfiguring Redis retires the previous subscriber.** `redis url: ...`
106
+ after a registry exists (the DSL with `active_record` listed first, or a
107
+ dev reload) started a second subscriber over the first, which stayed
108
+ blocked on the old subscription out of reach of shutdown. New
109
+ `Registry#redis_adapter=` stops the running subscriber before swapping;
110
+ `Magick.adapter_registry=` retires the registry it replaces (the Railtie's
111
+ default one, the previous `configure`'s); starting a subscriber while one
112
+ is alive is a no-op. Subscriber threads carry a generation so a retired one
113
+ lets go of its retry loop instead of leaking.
114
+
115
+ - **A subscriber that cannot subscribe is no longer silent in production.**
116
+ The failure is reported through `AdapterFailure` (error log + event,
117
+ operation `subscribe`) in every environment — first failure at once, then at
118
+ most once per five minutes while it keeps failing — and the recovery is
119
+ logged. A subscription that ends without a shutdown is treated as a lost
120
+ connection and resubscribed instead of leaving a live-looking thread that
121
+ hears nothing. `Registry#subscriber_running?` is true only while Redis has
122
+ acknowledged the SUBSCRIBE, and `Magick.health` exposes it together with
123
+ the last subscriber error, the refresh interval, the time of the last source
124
+ refresh and the async write backlog, for host health checks.
125
+
126
+ - **Pub/Sub subscriber shuts down cleanly.** The subscriber thread's early-exit
127
+ guards used `return` inside a block, which raises `LocalJumpError` instead of
128
+ ending the thread. `Registry#shutdown` re-raised it out of `Thread#join`, so
129
+ shutting down a registry with a live Redis subscription raised. The guards now
130
+ use `next`.
131
+
132
+ - **A Redis outage no longer propagates stale flags across the fleet.** A failed
133
+ or dropped Redis write was reported (above) but the cache invalidation was
134
+ published anyway, so every peer dutifully reloaded the *pre-toggle* value from
135
+ Redis and kept it for the rest of the breaker's timeout. Invalidation is now
136
+ published only after Redis has genuinely accepted the write — on the sync
137
+ path, the async path and `#delete` alike. Underneath, `CircuitBreaker#call`
138
+ raises the new `Magick::CircuitOpenError` (a `Magick::AdapterError`) instead
139
+ of returning a falsey value indistinguishable from a backend that stored
140
+ `false`.
141
+
142
+ - **Read paths are protected by the circuit breaker.** Only writes went through
143
+ it; `get`, `exists?`, `all_features`, `get_all_data`, `preload!` and the
144
+ Pub/Sub publish all called Redis directly, so an open circuit protected almost
145
+ nothing.
146
+
147
+ - **The default Redis client sets explicit connect, read and write timeouts**
148
+ (1s each) instead of inheriting redis-rb's 5s defaults. A Redis that
149
+ black-holes packets never refuses the connection, so an untimed client pinned
150
+ request threads for 5s per call while the breaker sat open with no errors to
151
+ count. Override individually: `redis url: ..., read_timeout: 2.0`.
152
+
153
+ - **A failed half-open probe re-opens the circuit immediately.** Moving to
154
+ half-open reset the failure count, so re-opening needed the full threshold
155
+ again and a permanently dead Redis absorbed several requests per timeout
156
+ cycle. Half-open now admits exactly one probe.
157
+
158
+ - **`Registry#exists?` is fail-safe.** It was the one read method with no
159
+ rescue, so it propagated adapter errors to callers outside the fail-safe
160
+ evaluation path, breaking the documented "never raises" contract. It now
161
+ returns `false` when the backend is unavailable; `all_features` is likewise
162
+ fail-safe.
163
+
164
+ - **Cross-container cache invalidation is no longer dropped.** Invalidation
165
+ messages now carry the publishing registry's identity (`Registry#publisher_id`,
166
+ re-minted after a fork), and a subscriber ignores only the messages it
167
+ published itself. The previous 2.0s `LOCAL_WRITE_TTL` window suppressed *any*
168
+ invalidation for a feature this process had written recently — including a
169
+ peer's — so two containers toggling the same flag inside that window each
170
+ discarded the other's message and kept serving their own value while the
171
+ shared store held one of them. Nothing healed the split: a feature's cached
172
+ value is only cleared by an explicit reload. The window is gone, not
173
+ shortened.
174
+
175
+ - **Async Redis writes are serialized and bounded.** `async_updates enabled:
176
+ true` spawned one thread per write, with no ordering and no cap. Two writes to
177
+ the same feature could land out of order, leaving memory with the newer value
178
+ and Redis with the older one — permanently divergent, with the trailing
179
+ Pub/Sub publish telling every other process to load the stale value. 200
180
+ writes also meant 200 threads and 200 Redis connections, enough for an Admin
181
+ UI bulk toggle or a boot-time DSL apply to exhaust the connection pool. Async
182
+ writes now go through a single `Magick::Adapters::AsyncWriter` thread per
183
+ registry, draining a bounded FIFO queue: writes reach Redis in the order they
184
+ were issued, and a burst costs one thread whatever its size.
185
+
186
+ - **Version numbers are allocated by the shared store.** Version history
187
+ clobbered itself across processes — two containers over one shared backend
188
+ destroyed each other's snapshots and disagreed about what a version number
189
+ contained. The next number now comes from an atomic counter kept beside the
190
+ history — a row-locked update on the ActiveRecord row, or Redis `HSETNX` +
191
+ `HINCRBY` — instead of being computed from a window each process memoized on
192
+ first read and never re-read. Two processes appending at the same time can no
193
+ longer be handed the same number.
194
+
195
+ - **Every append re-reads the current history.** The process-local hot-window
196
+ cache is gone; reads and appends both go to the store.
197
+
198
+ - **One store key per snapshot.** The hot window is now written as
199
+ `version_<n>` keys (the layout the ActiveRecord archive already used) rather
200
+ than as a single `versions` list that every append rewrote wholesale. An
201
+ append no longer overwrites entries another process wrote in between, and the
202
+ archive is no longer clobbered by a locally-computed number colliding with
203
+ one already in use.
204
+
205
+ `version_<n>` keys rather than as a single `versions` list that every append
206
+ rewrote wholesale. An append no longer overwrites entries another process
207
+ wrote in between, and the archive is no longer clobbered by a
208
+ locally-computed number colliding with one already in use.
209
+ - **Reads prefer the shared store.** Where memory, Redis and the archive
210
+ disagree about a number (only possible for history written before this
211
+ change), the durable shared copy wins, so a version resolves to the same
212
+ snapshot in every process. With no Redis configured, the archive also backs
213
+ the hot window — the memory adapter only ever holds what its own process
214
+ wrote.
215
+
216
+ - **Version counters resume above surviving history.** A store whose counter is
217
+ missing (upgrade, Redis flush, restored dump) is seeded from the highest
218
+ number still visible, including the archive, instead of restarting at 1 over
219
+ existing snapshots.
220
+
221
+ - **Audit entries are persisted by default.** The audit log's default adapter
222
+ was a no-op, so entries lived only in a per-process in-memory ring and were
223
+ lost on restart — in a multi-container deployment each process could only see
224
+ the changes it had made itself, which 1.5.0's full audit coverage made much
225
+ more visible. Every entry is now written to the adapters that outlive the
226
+ process (Redis and/or ActiveRecord) under a reserved `__magick_audit:<feature>`
227
+ namespace, so history survives a restart and one process can read what another
228
+ wrote. `Magick.audit_log.entries` merges the shared history with this
229
+ process's ring. A memory-only deployment still keeps the ring only —
230
+ `Magick.audit_log.durable?` reports which one you have.
231
+
232
+ - **Audit retention is tiered and documented.** The process-local ring keeps
233
+ `max_entries` (default 10,000) entries across all features; the durable store
234
+ keeps `retention` (default 200) entries **per feature**. Both are
235
+ configurable: `audit_log retention: 500, max_entries: 20_000`.
236
+
237
+ - **The audit adapter write left the ring lock.** Both the durable write and a
238
+ host-supplied adapter's `append` now run outside the mutex that guards the
239
+ in-memory ring, so a database-backed sink no longer serializes every feature
240
+ mutation in the process behind a single lock. An exception raised by a host
241
+ adapter is logged instead of aborting the mutation.
242
+
243
+ - **`audit_log enabled: false` now actually opts out.** It previously left in
244
+ place the default audit log that `Magick.configure` creates, so entries were
245
+ recorded anyway; `Magick.audit_log` is now `nil` as documented.
246
+
247
+ - **Bookkeeping namespaces stay out of the feature cache.** Version snapshots
248
+ and audit history are no longer preloaded into the memory cache or returned
249
+ by the Admin UI's bulk refresh, alongside the existing filtering in
250
+ `all_features`. Both the boot-time preload (`Magick.preload!`, run in every
251
+ worker) and the Admin UI's per-render source refresh previously pulled the
252
+ entire version archive into the memory adapter — measured at ~37 KB for 120
253
+ toggles of one flag, so tens of megabytes per worker on an install with a few
254
+ thousand versions across a few hundred flags, and a visibly slow admin index.
255
+ The reserved namespaces are now filtered **in the store** (a SQL `NOT LIKE`,
256
+ a Redis key filter applied before the values are fetched), so those rows are
257
+ never read at all rather than read and discarded.
258
+
259
+ - **Appending a version writes one version.** The ActiveRecord archive keeps one
260
+ row per snapshot instead of one key per snapshot inside the feature's single
261
+ row. That adapter's write path reads a whole row, merges one key and writes it
262
+ all back under a row lock, so appending version N rewrote all N-1 predecessors
263
+ with it. The version counter moved to a row of its own for the same reason.
264
+ Appending to a 107-version history now writes 761 bytes where it previously
265
+ wrote the whole 33 KB archive.
266
+
267
+ - **Adapter write failures are visible in production.** A failed Redis or
268
+ ActiveRecord write in the registry used to be reported with a bare `warn`
269
+ gated on `Rails.env.development?` — production got no log line and no event,
270
+ even though the memory cache had already been written and is never rolled
271
+ back, leaving that process serving a value no other process had. Every failed
272
+ or dropped write in `set`, `set_all_data`, `delete`, the async write path and
273
+ the cache-invalidation publish now logs at **error severity in every
274
+ environment** (through `Rails.logger` when present, `$stderr` otherwise,
275
+ sanitized via `Magick::LogSafe`) and emits a
276
+ `magick.feature_flag.adapter_write_failed` event so hosts can alert on the
277
+ divergence. Reporting never raises and never turns a partial write into an
278
+ exception for the caller. New `Magick::AdapterFailure` module.
279
+
280
+ - **Writes dropped by an open circuit breaker are reported too.** Once the
281
+ breaker tripped, Redis writes were skipped with no signal at all — silence
282
+ during exactly the window in which divergence accumulates. Each dropped write
283
+ now reports with `reason: "circuit breaker open"`.
284
+
285
+ - **Registry write paths contain any backend error, not just `AdapterError`.**
286
+ A driver exception that escaped an adapter's own wrapping (`IOError`,
287
+ `Redis::CannotConnectError`, …) used to propagate out of `Registry#set` /
288
+ `#set_all_data` to the caller.
289
+
290
+ - **Rails 8.1 structured events are actually emitted.** `Magick::Rails::Events`
291
+ is nested inside `Magick::Rails`, so its bare `Rails` constant resolved
292
+ lexically to that enclosing module rather than to the framework. `rails81?`
293
+ was therefore always false and *every* event in the gem was silently a no-op
294
+ inside a real Rails app. Now spelled `::Rails`. Hosts that subscribed to
295
+ `magick.feature_flag.*` and saw nothing will start receiving events.
296
+
297
+ - **Feature dependencies are persisted.** `add_dependency` / `remove_dependency`
298
+ mutated an in-process list that was never written to storage and never read
299
+ back on load, so a prerequisite relationship was invisible to every other
300
+ container and vanished on restart — and because an unknown prerequisite is
301
+ treated as satisfied, every other process went on serving the dependent
302
+ feature as live. Dependencies now live under the feature's `dependencies`
303
+ adapter key next to its value and targeting: writes go to the backend and
304
+ publish cache invalidation, loads restore them, and they survive export,
305
+ import and rollback. A prerequisite that this process never declared is
306
+ resolved from the shared backend rather than treated as unknown.
307
+
308
+ ### Changes
309
+
310
+ - **A non-callable `require_role` is rejected instead of ignored.**
311
+ `config.require_role = :admin` (or any other non-callable) used to pass the
312
+ nil guard, fail the callable check, and fall through to no authentication at
313
+ all, leaving the panel open while the operator believed it was locked. It now
314
+ raises `Magick::ConfigurationError` (new) at assignment time; a non-callable
315
+ hook that reaches the config another way denies the request. Leaving
316
+ `require_role` nil still permits access, so hosts gating at the router are
317
+ unaffected.
318
+
319
+ - **New adapter primitives for prefix-scoped bulk loads.**
320
+ `Magick::Adapters::Base` gains `#load_features_data_with_prefix(prefix)` and
321
+ `#load_features_data_without_prefixes(prefixes)`, overridden by the Redis and
322
+ ActiveRecord adapters to filter in the store. The inherited defaults load
323
+ everything and filter in Ruby: correct, but they still read what they discard.
324
+
325
+ - **New adapter primitives for shared counters.** `Magick::Adapters::Base` gains
326
+ `#next_sequence(feature_name, key, floor:)` and
327
+ `#delete_key(feature_name, key)`, implemented atomically by the memory, Redis
328
+ and ActiveRecord adapters and exposed on the registry. Custom adapters
329
+ backed by a store shared between processes must override `#next_sequence`;
330
+ the inherited default is a read-modify-write, correct only for a store one
331
+ process can reach. Version bookkeeping treats both as best-effort, so an
332
+ adapter that implements neither still records versions — with per-process
333
+ numbering and an unpruned hot window, as before.
334
+
335
+ - **`Magick::AuditLog::Entry#id`** — every audit entry carries a unique,
336
+ chronologically sortable id (also present in `to_h`), which is what
337
+ de-duplicates entries read back from more than one adapter.
338
+
339
+ - **`audit_log persist: false`** keeps the in-memory ring and any host-supplied
340
+ adapter but writes nothing to Redis/ActiveRecord, for hosts whose own sink is
341
+ the system of record.
342
+
343
+ - **`Feature#replace_dependencies(list)`** — wholesale prerequisite write (the
344
+ list is the new set, `[]` clears it), recorded as one audit entry and one
345
+ version. `Magick.import` applies dependencies through it: a payload without a
346
+ `dependencies` key leaves stored prerequisites alone, an explicit list
347
+ (including `[]`) replaces them.
348
+
349
+ - **`Magick.unknown_dependency_policy`** (`:satisfied` default, `:unsatisfied`;
350
+ also `unknown_dependency_policy :unsatisfied` in the configuration DSL) makes
351
+ the treatment of a prerequisite that exists neither in this process nor in the
352
+ backend explicit rather than incidental. Either way the name is reported on
353
+ stderr once per process, so the fail-open path is no longer silent.
354
+
355
+ - **Declared vs stored dependencies.** `dependencies:` in the DSL seeds the
356
+ stored set; after that stored state wins, so a dependency added at runtime is
357
+ not erased by processes booting with the older declaration, and a process that
358
+ declares nothing never writes. A declaration that changed since it was last
359
+ recorded (tracked under the `declared_dependencies` key) replaces the stored
360
+ set, so editing `dependencies:` still takes effect on the next boot. Deleting
361
+ the declaration is not a change — remove the prerequisite explicitly.
362
+
363
+ - **`add_dependency` on a prerequisite already present is a no-op** (no audit
364
+ entry, no version). A self-dependency raises `ArgumentError`, and a dependency
365
+ cycle evaluates as unsatisfied and is reported instead of raising
366
+ `SystemStackError` — which, not being a `StandardError`, escaped the fail-safe
367
+ rescue in `#enabled?`.
368
+ - **`async_updates` takes `queue_limit:` (default 1000) and `enqueue_timeout:`
369
+ (default 5 seconds).** When the queue is full a caller blocks for up to
370
+ `enqueue_timeout` — real backpressure — and only then is the write dropped.
371
+ Blocking forever would turn a wedged Redis into an application-wide stall, and
372
+ running the write inline would let it overtake the writes already queued for
373
+ that feature, which is the reordering being fixed. A dropped write is reported
374
+ through the same channel as a failed one (error log +
375
+ `magick.feature_flag.adapter_write_failed`, `reason:` naming the full queue),
376
+ never silently discarded.
377
+
378
+ - **Shutdown drains pending async writes** within the shutdown timeout, before
379
+ the Pub/Sub connection is closed, and reports whatever does not fit rather
380
+ than waiting on it — shutdown stays bounded. Writes issued after shutdown are
381
+ performed inline instead of being lost.
382
+
383
+ - **`Registry#async_writer` / `Registry#pending_async_writes`** expose the
384
+ writer and its backlog for observability.
385
+
386
+ ### Development
387
+
388
+ - The `redis` gem is a development dependency, and CI runs a Redis service, so
389
+ the Redis adapter, Pub/Sub cache invalidation, and circuit breaker are
390
+ exercised on every push. Run them locally with `bundle exec rake spec:redis`;
391
+ the default `bundle exec rspec` still needs no external services.
392
+
393
+ - The Admin UI specs boot a throwaway Rails application with the engine mounted
394
+ (`spec/rails_helper.rb`) and drive it over rack-test, so authentication
395
+ is asserted on every route the engine exposes rather than on a setting that
396
+ merely round-trips. `actionpack`, `actionview`, `railties` and `rack-test`
397
+ are development dependencies; the specs skip when they are absent.
398
+
399
+ ### Upgrading
400
+
401
+ - **Invalidation messages changed shape.** They are now JSON
402
+ (`{"feature":…,"publisher":…}`) rather than a bare feature name. An upgraded
403
+ process still acts on the bare names an un-upgraded one publishes, so it never
404
+ misses a peer's change; an un-upgraded process ignores the JSON and stays
405
+ stale until its next write. Finish the rollout before relying on
406
+ cross-container invalidation.
407
+
408
+ - **Version history written by 1.5.0 stays readable.** The old single-list
409
+ window is still read, and the first append after the upgrade migrates its
410
+ entries to `version_<n>` keys and continues numbering above them. Nothing to
411
+ run by hand. During a rolling deploy, processes still on the old code keep
412
+ numbering locally, so finish the rollout before relying on the guarantee.
413
+
414
+ - **Archives written before this release stay readable.** Snapshots kept as
415
+ `version_<n>` keys inside the feature's `__magick_versions:<name>` row are
416
+ read in place, so `get_versions` and `rollback` keep working across the
417
+ upgrade. They are left where they are rather than rewritten: nothing writes
418
+ to that row any more, so it costs one read and never grows. New snapshots go
419
+ to `__magick_versions:<name>#v<n>` rows and numbering continues above the old
420
+ history. Again, nothing to run by hand.
421
+
422
+ ## 1.6.0 — 2026-08-05
423
+
424
+ Wire targeting contract for control-plane APIs. The gem now ships the two
425
+ primitives a flag-management endpoint needs — a canonical serializer and a
426
+ wholesale targeting write — while the host app keeps routes and auth.
427
+
428
+ ### Features
429
+ - **`Feature#as_json`** — canonical wire-format flag payload (string keys).
430
+ The `"targeting"` key is **always present** (`{}` = no targeting), list
431
+ rules are arrays of strings, percentages are floats, and the internal
432
+ `:variants` entry never appears inside `targeting`. Works directly with
433
+ `render json: feature` / `render json: Magick.features.values`.
434
+ - **`Feature#replace_targeting(payload)`** — wholesale, declarative
435
+ targeting write: the payload is the new state, keys absent from it are
436
+ removed, `{}` clears everything. Lenient input (string/symbol keys, plural
437
+ aliases, scalars for lists, numeric strings), strict validation: unknown
438
+ keys or invalid values raise the new `Magick::InvalidTargetingError`
439
+ before anything is applied (all-or-nothing) — map it to a 422. Records one
440
+ audit entry and one version snapshot per call. A/B variants survive the
441
+ replace untouched.
442
+ - **`Magick::TargetingPayload`** — the shared normalizer/serializer behind
443
+ both primitives; `Feature#targeting` is now a public reader.
444
+
445
+ ### Changes
446
+ - **Admin UI targeting form** submits through `replace_targeting`: one
447
+ `replace_targeting` audit entry + one version per save, instead of one
448
+ entry per changed rule. Rules the form has no fields for (IP, date range,
449
+ custom attributes, complex conditions, group targeting) are carried over
450
+ unchanged.
451
+ - **`Magick.import`** applies targeting through `replace_targeting` as well:
452
+ imported targeting now replaces the feature's existing targeting wholesale,
453
+ and invalid targeting payloads raise `ImportError` instead of being
454
+ silently applied or skipped. Exports from older gem versions (which leaked
455
+ `variants` into the targeting hash) still import cleanly.
456
+ - **`as_json` variants** read the authoritative store
457
+ (`targeting[:variants]`), so the wire `variants` list is populated
458
+ (`to_h`'s legacy top-level `variants` was always empty).
459
+
460
+ ## 1.5.0 — 2026-08-05
461
+
462
+ Every save now creates a version, and the audit log covers every mutation.
463
+ Previously versions were only written by explicit `save_version` calls, and
464
+ the audit log fired only from `set_value`.
465
+
466
+ ### Features
467
+ - **Automatic versioning.** Every state-changing operation on a feature
468
+ (value, status, group, all targeting/exclusion mutations, variants,
469
+ dependencies, delete) records a version snapshot through a single choke
470
+ point (`Feature#record_change`). A thread-local reentrancy guard ensures one
471
+ logical operation records exactly once — `enable` no longer surfaces as an
472
+ internal `set_value`.
473
+ - **Full audit coverage with real action names.** Audit entries (and
474
+ `magick.feature_flag.audit_logged` events) now carry the actual operation:
475
+ `enable`, `disable`, `enable_for_user`, `exclude_role`, `set_status`,
476
+ `set_group`, `delete`, `rollback`, … Subscribers matching on `'set_value'`
477
+ for UI toggles will see the new names.
478
+ - **Adapter-backed, tiered version history.** The hot window (last
479
+ `max_versions`, default 50, configurable via
480
+ `versioning enabled: true, max_versions: N`) lives in memory/Redis and is
481
+ shared across containers; the ActiveRecord adapter keeps an unlimited
482
+ archive (`__magick_versions:<name>` row) that survives restarts, Redis
483
+ flushes, and even feature deletion. `get_versions(name, all: true)` merges
484
+ the archive; rollback reaches versions that left the hot window.
485
+ - **Actor attribution.** `Magick.with_actor(id) { ... }` stamps audit
486
+ `user_id` and version `created_by` for every change in the block; explicit
487
+ `user_id:` kwargs still win. The Admin UI attributes changes via the new
488
+ `Magick::AdminUI.configure { |c| c.current_actor = ->(controller) { ... } }`
489
+ hook (around_action on every request).
490
+ - **Definition mode.** `Magick.definition_mode { ... }` suppresses recording
491
+ while declarative definitions are (re)applied; the railtie wraps the boot
492
+ load of `config/features.rb`, so container boots no longer would flood
493
+ history with identical snapshots.
494
+
495
+ ### Fixes
496
+ - **Rollback restores state wholesale.** Previously rollback re-applied only
497
+ user/group/role targeting additively (never clearing current rules),
498
+ ignored exclusions/percentages/date ranges/variants, and skipped falsy
499
+ values (`if feature_data[:value]` — a boolean `false` was never restored).
500
+ It now replaces value (including `false`/empty), status, group, the entire
501
+ targeting hash, and dependencies — and records the rollback itself as a new
502
+ version instead of rewriting history.
503
+ - **Version history survives restarts.** `get_versions` previously read only
504
+ a per-process in-memory list; adapter-written snapshots were never read
505
+ back. History is now rehydrated from the adapters.
506
+ - **`versioning enabled: false` is honored.** `Config#apply!` used to re-run
507
+ the DSL methods with their defaults, stomping explicit
508
+ `enabled: false` settings for versioning/audit_log.
509
+ - **Single audit event per change.** `set_value` previously emitted
510
+ `magick.feature_flag.audit_logged` twice (once itself, once via
511
+ `AuditLog#log`).
512
+
513
+ ## 1.4.3 — 2026-06-01
514
+
515
+ Fixes the multi-process/multi-container "toggle doesn't take effect until I
516
+ click again" bug in the Admin UI.
517
+
518
+ ### Correctness
519
+ - **Admin UI now renders authoritative state.** In a load-balanced deployment
520
+ the enable/disable POST and the redirected GET land on different
521
+ processes/containers, so the process rendering the page could show its own
522
+ stale in-memory cache until Pub/Sub caught up. `index`/`show`/`edit` now read
523
+ straight from the shared backend (ActiveRecord → Redis) via the new
524
+ `Adapters::Registry#authoritative_get_all_data` / `#refresh_all_from_source`
525
+ and `Feature#reload_from_source!`, bypassing the local memory cache.
526
+ - **Cross-process cache invalidation no longer drops the final state.** A single
527
+ `enable`/`disable` emits two Pub/Sub publishes (targeting, then value); the
528
+ old 100 ms reload debounce dropped the second, leaving other processes holding
529
+ the old value until their memory TTL (up to hours) expired. The subscriber now
530
+ reloads on every valid invalidation (`Registry#process_cache_invalidation`);
531
+ each reload reads complete state, so it stays idempotent.
532
+
533
+ ### Reliability
534
+ - **Forked workers self-heal their Pub/Sub subscriber.** Under Puma
535
+ `preload_app!`, workers inherit a dead subscriber thread and — in production —
536
+ `config.to_prepare` does not re-run to revive it. A new Rack middleware
537
+ (`Magick::Rails::SubscriberMiddleware`) calls the pid-guarded
538
+ `ensure_subscriber!` per request, so each worker starts its own subscriber on
539
+ first request. Near-free no-op in single-mode Puma. README corrected
540
+ accordingly.
541
+
542
+ ## 1.4.1 — 2026-04-16
543
+
544
+ Follow-up to 1.4.0 closing the nine acknowledged audit misses.
545
+
546
+ ### Security
547
+ - `ExportImport.import` caps list size (10_000, overridable via
548
+ `MAGICK_MAX_IMPORT_FEATURES`) and rejects non-Hash entries with
549
+ `Magick::ExportImport::ImportError` (audit P1-S5).
550
+ - New `Magick::LogSafe.sanitize` wrapper; every `warn`/`Rails.logger.*`
551
+ call that interpolates a feature name or exception message now runs
552
+ its input through it to block log injection (audit P2-S2).
553
+ - Admin UI `update_targeting` and `update_variants` validate that their
554
+ payloads are Hash-like before iterating; malformed shapes redirect
555
+ with a generic alert instead of 500-ing with a stack trace (audit P2-S3).
556
+
557
+ ### Correctness
558
+ - `Adapters::Base#set_all_data` now raises `NotImplementedError` so
559
+ custom adapters fail loudly instead of silently dropping bulk writes
560
+ (audit P2-Co6).
561
+ - `Versioning#save_version` computes the next version number and
562
+ appends under the same mutex so concurrent saves can't collide
563
+ (audit P2-C10). `get_versions` returns a dup'd snapshot.
564
+
565
+ ### Resource hygiene
566
+ - `PerformanceMetrics.record_async` pre-caps `@metrics` at the
567
+ `METRICS_RING_CAP` constant; drops the dead post-insert shift
568
+ (audit P0-C2).
569
+ - Async Redis writes (`Registry#spawn_async_write`) now `rescue
570
+ StandardError` and log so failures are visible instead of silently
571
+ killing the thread (audit P1-C4).
572
+ - `Memory#set` and `#set_all_data` trigger `cleanup_expired_if_needed`,
573
+ so write-heavy processes evict expired TTLs between 30s sweeps
574
+ (audit P1-C8).
575
+
576
+ ### Tests
577
+ - `versioning_spec.rb` covers sequential versions, get_versions
578
+ snapshot semantics, 50-way concurrent save, and rollback.
579
+ - `log_safe_spec.rb` covers control-char replacement, truncation,
580
+ custom max, and non-string inputs.
581
+ - `export_import_roundtrip_spec.rb` gains input-validation tests.
582
+
583
+ ### Docs
584
+ - RAILS8_EVENTS.md documents `feature_enabled_globally` /
585
+ `feature_disabled_globally` (were missing from the event list).
586
+ - Install generator template notes `Magick::AdminUI.configure` auth
587
+ wiring and when to call `Magick.shutdown!` in non-Rails processes.
588
+
589
+ ## 1.4.0 — 2026-04-16
590
+
591
+ Hardening release driven by a full audit (concurrency, security, correctness,
592
+ coverage). No breaking changes. Major highlights:
593
+
594
+ ### Security
595
+
596
+ - **Admin UI**: `FeaturesController` and `StatsController` now include
597
+ `ActionController::RequestForgeryProtection` and call `protect_from_forgery
598
+ with: :exception`. Until this release the Admin UI was vulnerable to CSRF
599
+ because inheriting `ActionController::Base` does not bring CSRF in by
600
+ default.
601
+ - **Admin UI**: `set_feature` no longer falls through to `Magick[name]`, which
602
+ would lazily create and persist a new feature from an attacker-chosen
603
+ `params[:id]`. Unknown IDs now 404/redirect.
604
+ - **Admin UI**: Exception messages are no longer echoed into flash banners;
605
+ they go to the server log and users see a generic "see server logs" message.
606
+ - **Admin UI helpers**: `feature_status_badge` returns `content_tag` so future
607
+ callers can't accidentally render user input through `raw`/`html_safe`.
608
+ - **Pub/Sub**: Incoming `feature_name` payloads must match a conservative
609
+ identifier pattern (`[a-zA-Z0-9_\-.:]{1,120}`); anything else is dropped,
610
+ preventing a neighbour tenant on a shared Redis DB from triggering reload
611
+ loops or memory growth.
612
+ - **Config**: `ConfigDSL.load_from_file` now resolves paths with `File.realpath`
613
+ and refuses anything outside the project tree unless
614
+ `MAGICK_ALLOW_CONFIG_EVAL=1` is set. This closes an RCE-by-path vector.
615
+
616
+ ### Correctness / Bug fixes
617
+
618
+ - **Graceful shutdown**: New `Magick.shutdown!` and `Adapters::Registry#shutdown`
619
+ cleanly terminate the Redis Pub/Sub subscriber thread. Without this, Puma /
620
+ Rails graceful stops hung on the blocking `Redis#subscribe` call. Wired into
621
+ the Railtie via `at_exit`.
622
+ - **Fork safety**: `Registry#ensure_subscriber!` and
623
+ `PerformanceMetrics#ensure_async_processor!` restart background threads
624
+ after a Puma worker fork so children don't share the parent's inherited
625
+ subscriber socket. Invoked from `config.to_prepare`.
626
+ - **`Magick.reset!`**: Now resets the lazily-initialised default adapter
627
+ registry singleton; previously tests and reconfigurations leaked the old
628
+ in-memory cache.
629
+ - **Export/Import**: `export` now emits `group`, `dependencies` and
630
+ `variants`. `import` applies every targeting key (inclusions and
631
+ exclusions, tags, IPs, date ranges, custom attributes, variants, and
632
+ dependencies) instead of silently dropping them.
633
+ - **IP targeting**: `Feature#enable_for_ip_addresses` and
634
+ `#exclude_ip_addresses` used to store the incoming array as a stringified
635
+ `'["1.2.3.4"]'`, so IP gating never actually worked. Both setters now
636
+ append each IP directly.
637
+ - **Orphaned classes**: `Magick::Targeting::Complex`, `CustomAttribute`,
638
+ `DateRange` and `IpAddress` existed in `lib/magick/targeting/` but were
639
+ never required. They're now wired into `lib/magick.rb`.
640
+
641
+ ### Resource hygiene
642
+
643
+ - **AuditLog** is now bounded via a ring-buffer-style cap (default 10_000,
644
+ configurable via `max_entries:`); `entries` returns a dup'd snapshot so
645
+ readers don't race with writers.
646
+ - **Registry**: `record_local_write` also sweeps stale tracking entries, so a
647
+ write-heavy / read-light process no longer leaks `@local_writes` and
648
+ `@last_reload_times`.
649
+ - **Redis SCAN** retries once with backoff on transient errors.
650
+
651
+ ### Tests
652
+
653
+ - 297+ specs covering `FeatureDependency`, all targeting strategies,
654
+ `CircuitBreaker` state transitions + concurrency, Registry shutdown +
655
+ fork safety, Redis integration (REDIS_URL-gated), `AuditLog` eviction,
656
+ `ConfigDSL.load_from_file` path validation, variant distribution, and
657
+ full export/import round-trip.
658
+
659
+ ## 1.3.1 — earlier
660
+
661
+ - Fix inverted dependency logic in `Feature#enable` / `#disable` cascade.
662
+
663
+ ## 1.3.0 — earlier
664
+
665
+ - A/B testing support with variant management.
666
+ - Documentation for anonymous user experiments and variant safety.
667
+
668
+ ## 1.2.x — earlier
669
+
670
+ - `magick-feature-flags` renamed + styles in Admin UI.
671
+
672
+ ---
673
+
674
+ For older releases see `git log`.