magick-feature-flags 1.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +674 -0
- data/README.md +546 -35
- data/app/controllers/magick/adminui/features_controller.rb +9 -18
- data/app/controllers/magick/adminui/stats_controller.rb +5 -0
- data/config/magick.rb.example +6 -2
- data/lib/generators/magick/active_record/active_record_generator.rb +2 -2
- data/lib/generators/magick/install/install_generator.rb +1 -1
- data/lib/generators/magick/install/templates/magick.rb +27 -6
- data/lib/magick/adapter_failure.rb +111 -0
- data/lib/magick/adapters/active_record.rb +129 -24
- data/lib/magick/adapters/async_writer.rb +287 -0
- data/lib/magick/adapters/base.rb +49 -0
- data/lib/magick/adapters/memory.rb +26 -0
- data/lib/magick/adapters/redis.rb +100 -22
- data/lib/magick/adapters/registry.rb +724 -215
- data/lib/magick/admin_ui/authentication.rb +65 -0
- data/lib/magick/admin_ui.rb +26 -1
- data/lib/magick/audit_log.rb +284 -19
- data/lib/magick/bulk_result.rb +73 -0
- data/lib/magick/circuit_breaker.rb +55 -11
- data/lib/magick/config.rb +167 -27
- data/lib/magick/dsl.rb +1 -1
- data/lib/magick/errors.rb +12 -0
- data/lib/magick/export_import.rb +32 -15
- data/lib/magick/feature.rb +305 -58
- data/lib/magick/performance_metrics.rb +168 -117
- data/lib/magick/rails/event_subscriber.rb +3 -3
- data/lib/magick/rails/events.rb +28 -4
- data/lib/magick/rails/railtie.rb +45 -69
- data/lib/magick/rails.rb +1 -1
- data/lib/magick/request_store_integration.rb +69 -0
- data/lib/magick/targeting_payload.rb +23 -0
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +282 -62
- data/lib/magick.rb +107 -10
- metadata +114 -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`.
|