magick-feature-flags 0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +363 -0
- data/lib/generators/magick/install/install_generator.rb +19 -0
- data/lib/generators/magick/install/templates/README +25 -0
- data/lib/generators/magick/install/templates/magick.rb +32 -0
- data/lib/magick/adapters/base.rb +27 -0
- data/lib/magick/adapters/memory.rb +113 -0
- data/lib/magick/adapters/redis.rb +97 -0
- data/lib/magick/adapters/registry.rb +133 -0
- data/lib/magick/audit_log.rb +65 -0
- data/lib/magick/circuit_breaker.rb +65 -0
- data/lib/magick/config.rb +179 -0
- data/lib/magick/dsl.rb +80 -0
- data/lib/magick/errors.rb +9 -0
- data/lib/magick/export_import.rb +82 -0
- data/lib/magick/feature.rb +665 -0
- data/lib/magick/feature_dependency.rb +28 -0
- data/lib/magick/feature_variant.rb +17 -0
- data/lib/magick/performance_metrics.rb +76 -0
- data/lib/magick/rails/event_subscriber.rb +55 -0
- data/lib/magick/rails/events.rb +236 -0
- data/lib/magick/rails/railtie.rb +94 -0
- data/lib/magick/rails.rb +7 -0
- data/lib/magick/targeting/base.rb +11 -0
- data/lib/magick/targeting/complex.rb +27 -0
- data/lib/magick/targeting/custom_attribute.rb +35 -0
- data/lib/magick/targeting/date_range.rb +17 -0
- data/lib/magick/targeting/group.rb +15 -0
- data/lib/magick/targeting/ip_address.rb +22 -0
- data/lib/magick/targeting/percentage.rb +24 -0
- data/lib/magick/targeting/request_percentage.rb +15 -0
- data/lib/magick/targeting/role.rb +15 -0
- data/lib/magick/targeting/user.rb +15 -0
- data/lib/magick/testing_helpers.rb +45 -0
- data/lib/magick/version.rb +5 -0
- data/lib/magick/versioning.rb +98 -0
- data/lib/magick.rb +143 -0
- metadata +123 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 26705304fe361a52c069dde57f271991a8e108bd83b812754928551fe8eb1c50
|
|
4
|
+
data.tar.gz: 025b53d5898a675b1d0dca7b66244ced692ad51fa1036ab0c5bdc1df929184e4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2f88df6bd3c9e236bb7a3cbffefa4b6a38e14b5d0d4ae3a85840c0473375c74bc0118c85d9646f2567e5615e581124d652ecd742c7ad89b70127cd443bae4f76
|
|
7
|
+
data.tar.gz: 26d30eb262dbd89fc78c3df40d46f8bd5f176ffd59720302bc9054f458cb4123de51d6b01051e8ad5e9eb3944bf4c430ddf1bf3f3f264369f3eb2764cdcf0d08
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Andrew Lobanov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# Magick
|
|
2
|
+
|
|
3
|
+
A performant and memory-efficient feature toggle gem for Ruby and Rails applications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Multiple Feature Types**: Boolean, string, and number feature flags
|
|
8
|
+
- **Flexible Targeting**: Enable features for specific users, groups, roles, or percentages
|
|
9
|
+
- **Dual Backend**: Memory adapter (fast) with Redis fallback (persistent)
|
|
10
|
+
- **Rails Integration**: Seamless integration with Rails, including request store caching
|
|
11
|
+
- **DSL Support**: Define features in a Ruby DSL file (`config/features.rb`)
|
|
12
|
+
- **Thread-Safe**: All operations are thread-safe for concurrent access
|
|
13
|
+
- **Performance**: Optimized for speed with memory-first caching strategy
|
|
14
|
+
- **Advanced Features**: Circuit breaker, audit logging, performance metrics, versioning, and more
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add this line to your application's Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem 'magick'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
And then execute:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
$ bundle install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or install it yourself as:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
$ gem install magick
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
After adding the gem to your Gemfile and running `bundle install`, generate the configuration file:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
rails generate magick:install
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This will create `config/initializers/magick.rb` with a basic configuration.
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
### Basic Configuration
|
|
49
|
+
|
|
50
|
+
The generator creates `config/initializers/magick.rb` with sensible defaults. You can also create it manually:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
Magick.configure do
|
|
54
|
+
# Configure Redis (optional)
|
|
55
|
+
redis url: ENV['REDIS_URL']
|
|
56
|
+
|
|
57
|
+
# Enable features
|
|
58
|
+
performance_metrics enabled: true
|
|
59
|
+
audit_log enabled: true
|
|
60
|
+
versioning enabled: true
|
|
61
|
+
warn_on_deprecated enabled: true
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Advanced Configuration
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
Magick.configure do
|
|
69
|
+
# Environment
|
|
70
|
+
environment Rails.env
|
|
71
|
+
|
|
72
|
+
# Memory TTL
|
|
73
|
+
memory_ttl 7200 # 2 hours
|
|
74
|
+
|
|
75
|
+
# Redis configuration
|
|
76
|
+
redis url: ENV['REDIS_URL'], namespace: 'magick:features'
|
|
77
|
+
|
|
78
|
+
# Circuit breaker settings
|
|
79
|
+
circuit_breaker threshold: 5, timeout: 60
|
|
80
|
+
|
|
81
|
+
# Async updates
|
|
82
|
+
async_updates enabled: true
|
|
83
|
+
|
|
84
|
+
# Enable services
|
|
85
|
+
performance_metrics enabled: true
|
|
86
|
+
audit_log enabled: true
|
|
87
|
+
versioning enabled: true
|
|
88
|
+
warn_on_deprecated enabled: true
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
### Basic Usage
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# Check if a feature is enabled
|
|
98
|
+
if Magick.enabled?(:new_dashboard)
|
|
99
|
+
# Show new dashboard
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# With context (user, role, etc.)
|
|
103
|
+
if Magick.enabled?(:premium_features, user_id: current_user.id, role: current_user.role)
|
|
104
|
+
# Show premium features
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Registering Features
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# Register a boolean feature
|
|
112
|
+
Magick.register_feature(:new_dashboard,
|
|
113
|
+
type: :boolean,
|
|
114
|
+
default_value: false,
|
|
115
|
+
description: "New dashboard UI"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Register a string feature
|
|
119
|
+
Magick.register_feature(:api_version,
|
|
120
|
+
type: :string,
|
|
121
|
+
default_value: "v1",
|
|
122
|
+
description: "API version to use"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Register a number feature
|
|
126
|
+
Magick.register_feature(:max_results,
|
|
127
|
+
type: :number,
|
|
128
|
+
default_value: 10,
|
|
129
|
+
description: "Maximum number of results"
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Feature Targeting
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
feature = Magick[:new_dashboard]
|
|
137
|
+
|
|
138
|
+
# Enable globally (for everyone, no targeting)
|
|
139
|
+
feature.enable
|
|
140
|
+
|
|
141
|
+
# Disable globally (for everyone, no targeting)
|
|
142
|
+
feature.disable
|
|
143
|
+
|
|
144
|
+
# Enable for specific user
|
|
145
|
+
feature.enable_for_user(123)
|
|
146
|
+
|
|
147
|
+
# Enable for specific group
|
|
148
|
+
feature.enable_for_group("beta_testers")
|
|
149
|
+
|
|
150
|
+
# Enable for specific role
|
|
151
|
+
feature.enable_for_role("admin")
|
|
152
|
+
|
|
153
|
+
# Enable for percentage of users (consistent)
|
|
154
|
+
feature.enable_percentage_of_users(25) # 25% of users
|
|
155
|
+
|
|
156
|
+
# Enable for percentage of requests (random)
|
|
157
|
+
feature.enable_percentage_of_requests(50) # 50% of requests
|
|
158
|
+
|
|
159
|
+
# Enable for date range
|
|
160
|
+
feature.enable_for_date_range('2024-01-01', '2024-12-31')
|
|
161
|
+
|
|
162
|
+
# Enable for IP addresses
|
|
163
|
+
feature.enable_for_ip_addresses('192.168.1.0/24', '10.0.0.1')
|
|
164
|
+
|
|
165
|
+
# Enable for custom attributes
|
|
166
|
+
feature.enable_for_custom_attribute(:subscription_tier, ['premium', 'enterprise'])
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### DSL Configuration
|
|
170
|
+
|
|
171
|
+
Create `config/features.rb`:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# Boolean features
|
|
175
|
+
boolean_feature :new_dashboard, default: false, description: "New dashboard UI"
|
|
176
|
+
boolean_feature :dark_mode, default: false, description: "Dark mode theme"
|
|
177
|
+
|
|
178
|
+
# String features
|
|
179
|
+
string_feature :api_version, default: "v1", description: "API version"
|
|
180
|
+
|
|
181
|
+
# Number features
|
|
182
|
+
number_feature :max_results, default: 10, description: "Maximum results per page"
|
|
183
|
+
|
|
184
|
+
# With status
|
|
185
|
+
feature :experimental_feature,
|
|
186
|
+
type: :boolean,
|
|
187
|
+
default_value: false,
|
|
188
|
+
status: :deprecated,
|
|
189
|
+
description: "Experimental feature (deprecated)"
|
|
190
|
+
|
|
191
|
+
# With dependencies (feature will only be enabled if dependencies are enabled)
|
|
192
|
+
boolean_feature :advanced_feature,
|
|
193
|
+
default: false,
|
|
194
|
+
description: "Advanced feature requiring base_feature",
|
|
195
|
+
dependencies: [:base_feature]
|
|
196
|
+
|
|
197
|
+
# Multiple dependencies
|
|
198
|
+
boolean_feature :premium_feature,
|
|
199
|
+
default: false,
|
|
200
|
+
description: "Premium feature requiring multiple features",
|
|
201
|
+
dependencies: [:base_feature, :auth_feature]
|
|
202
|
+
|
|
203
|
+
# Add dependencies after feature definition
|
|
204
|
+
add_dependency(:another_feature, :required_feature)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### In Controllers
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
class DashboardController < ApplicationController
|
|
211
|
+
def show
|
|
212
|
+
if Magick.enabled?(:new_dashboard, user_id: current_user.id, role: current_user.role)
|
|
213
|
+
render :new_dashboard
|
|
214
|
+
else
|
|
215
|
+
render :old_dashboard
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Advanced Features
|
|
222
|
+
|
|
223
|
+
#### Feature Variants (A/B Testing)
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
feature = Magick[:button_color]
|
|
227
|
+
feature.set_variants([
|
|
228
|
+
{ name: 'blue', value: '#0066cc', weight: 50 },
|
|
229
|
+
{ name: 'green', value: '#00cc66', weight: 30 },
|
|
230
|
+
{ name: 'red', value: '#cc0000', weight: 20 }
|
|
231
|
+
])
|
|
232
|
+
|
|
233
|
+
variant = feature.get_variant
|
|
234
|
+
# Returns 'blue', 'green', or 'red' based on weights
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
#### Feature Dependencies
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
feature = Magick[:advanced_feature]
|
|
241
|
+
feature.add_dependency(:base_feature)
|
|
242
|
+
# advanced_feature will only be enabled if base_feature is also enabled
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
#### Export/Import
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
# Export features
|
|
249
|
+
json_data = Magick.export(format: :json)
|
|
250
|
+
File.write('features.json', json_data)
|
|
251
|
+
|
|
252
|
+
# Import features
|
|
253
|
+
Magick.import(File.read('features.json'))
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
#### Versioning and Rollback
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
# Save current state as version
|
|
260
|
+
Magick.versioning.save_version(:my_feature, created_by: current_user.id)
|
|
261
|
+
|
|
262
|
+
# Rollback to previous version
|
|
263
|
+
Magick.versioning.rollback(:my_feature, version: 2)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
#### Performance Metrics
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
# Get average duration for feature checks
|
|
270
|
+
avg_duration = Magick.performance_metrics.average_duration(feature_name: :my_feature)
|
|
271
|
+
|
|
272
|
+
# Get most used features
|
|
273
|
+
most_used = Magick.performance_metrics.most_used_features(limit: 10)
|
|
274
|
+
|
|
275
|
+
# Get usage count
|
|
276
|
+
count = Magick.performance_metrics.usage_count(:my_feature)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
#### Audit Logging
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
# View audit log entries
|
|
283
|
+
entries = Magick.audit_log.entries(feature_name: :my_feature, limit: 100)
|
|
284
|
+
entries.each do |entry|
|
|
285
|
+
puts "#{entry.timestamp}: #{entry.action} by #{entry.user_id}"
|
|
286
|
+
end
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Architecture
|
|
290
|
+
|
|
291
|
+
### Adapters
|
|
292
|
+
|
|
293
|
+
Magick uses a dual-adapter strategy:
|
|
294
|
+
|
|
295
|
+
1. **Memory Adapter**: Fast, in-memory storage with TTL support
|
|
296
|
+
2. **Redis Adapter**: Persistent storage for distributed systems (optional)
|
|
297
|
+
|
|
298
|
+
The registry automatically falls back from memory to Redis if a feature isn't found in memory. When features are updated, both adapters are updated simultaneously.
|
|
299
|
+
|
|
300
|
+
#### Memory-Only Mode
|
|
301
|
+
|
|
302
|
+
If Redis is not configured, Magick works in **memory-only mode**:
|
|
303
|
+
- ✅ Fast, zero external dependencies
|
|
304
|
+
- ✅ Perfect for single-process applications or development
|
|
305
|
+
- ⚠️ **No cross-process cache invalidation** - each process has isolated cache
|
|
306
|
+
- ⚠️ Changes in one process won't be reflected in other processes
|
|
307
|
+
|
|
308
|
+
#### Redis Mode (Recommended for Production)
|
|
309
|
+
|
|
310
|
+
With Redis configured:
|
|
311
|
+
- ✅ Cross-process cache invalidation via Redis Pub/Sub
|
|
312
|
+
- ✅ Persistent storage across restarts
|
|
313
|
+
- ✅ Zero Redis calls on feature checks (only memory lookups)
|
|
314
|
+
- ✅ Automatic cache invalidation when features change in any process
|
|
315
|
+
|
|
316
|
+
### Feature Types
|
|
317
|
+
|
|
318
|
+
- `:boolean` - True/false flags
|
|
319
|
+
- `:string` - String values
|
|
320
|
+
- `:number` - Numeric values
|
|
321
|
+
|
|
322
|
+
### Feature Status
|
|
323
|
+
|
|
324
|
+
- `:active` - Feature is active and can be enabled
|
|
325
|
+
- `:inactive` - Feature is disabled for everyone
|
|
326
|
+
- `:deprecated` - Feature is deprecated (can be enabled with `allow_deprecated: true` in context)
|
|
327
|
+
|
|
328
|
+
## Testing
|
|
329
|
+
|
|
330
|
+
Use the testing helpers in your RSpec tests:
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
RSpec.describe MyFeature do
|
|
334
|
+
it 'works with feature enabled' do
|
|
335
|
+
with_feature_enabled(:new_feature) do
|
|
336
|
+
# Test code here
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
it 'works with feature disabled' do
|
|
341
|
+
with_feature_disabled(:new_feature) do
|
|
342
|
+
# Test code here
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## Development
|
|
349
|
+
|
|
350
|
+
After checking out the repo, run:
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
bundle install
|
|
354
|
+
rspec
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Contributing
|
|
358
|
+
|
|
359
|
+
Bug reports and pull requests are welcome on GitHub.
|
|
360
|
+
|
|
361
|
+
## License
|
|
362
|
+
|
|
363
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path('templates', __dir__)
|
|
7
|
+
|
|
8
|
+
desc 'Creates a Magick configuration file at config/initializers/magick.rb'
|
|
9
|
+
|
|
10
|
+
def create_initializer
|
|
11
|
+
template 'magick.rb', 'config/initializers/magick.rb'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show_readme
|
|
15
|
+
readme 'README' if behavior == :invoke
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
== Magick Install Generator
|
|
2
|
+
|
|
3
|
+
The generated configuration includes:
|
|
4
|
+
- Performance metrics tracking
|
|
5
|
+
- Audit logging
|
|
6
|
+
- Versioning support
|
|
7
|
+
- Deprecation warnings
|
|
8
|
+
|
|
9
|
+
Optional features (commented out by default):
|
|
10
|
+
- Redis adapter configuration
|
|
11
|
+
- Memory TTL customization
|
|
12
|
+
- Circuit breaker settings
|
|
13
|
+
- Async updates
|
|
14
|
+
|
|
15
|
+
You can customize the configuration by editing the generated file.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
rails generate magick:install
|
|
19
|
+
|
|
20
|
+
For more information, see:
|
|
21
|
+
- README.md
|
|
22
|
+
- config/magick.rb.example
|
|
23
|
+
|
|
24
|
+
This generator creates a basic Magick configuration file at:
|
|
25
|
+
config/initializers/magick.rb
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Magick Configuration
|
|
2
|
+
# Generated by: rails generate magick:install
|
|
3
|
+
#
|
|
4
|
+
# For more configuration options, see:
|
|
5
|
+
# https://github.com/andrew-woblavobla/magick
|
|
6
|
+
|
|
7
|
+
Magick.configure do
|
|
8
|
+
# Configure Redis (optional - gem works without it using memory-only adapter)
|
|
9
|
+
# Uncomment to use Redis:
|
|
10
|
+
# redis url: ENV['REDIS_URL'], namespace: 'magick:features'
|
|
11
|
+
|
|
12
|
+
# Configure memory adapter TTL (default: 3600 seconds = 1 hour)
|
|
13
|
+
# memory_ttl 7200 # 2 hours
|
|
14
|
+
|
|
15
|
+
# Configure circuit breaker for Redis failures
|
|
16
|
+
# circuit_breaker threshold: 5, timeout: 60
|
|
17
|
+
|
|
18
|
+
# Enable async updates for Redis (non-blocking, improves performance)
|
|
19
|
+
# async_updates enabled: true
|
|
20
|
+
|
|
21
|
+
# Enable performance metrics tracking
|
|
22
|
+
performance_metrics enabled: true
|
|
23
|
+
|
|
24
|
+
# Enable audit logging (tracks who changed what, when)
|
|
25
|
+
audit_log enabled: true
|
|
26
|
+
|
|
27
|
+
# Enable versioning support (allows rollback)
|
|
28
|
+
versioning enabled: true
|
|
29
|
+
|
|
30
|
+
# Enable deprecation warnings in logs
|
|
31
|
+
warn_on_deprecated enabled: true
|
|
32
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Adapters
|
|
5
|
+
class Base
|
|
6
|
+
def get(feature_name, key)
|
|
7
|
+
raise NotImplementedError, "#{self.class} must implement #get"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def set(feature_name, key, value)
|
|
11
|
+
raise NotImplementedError, "#{self.class} must implement #set"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def delete(feature_name)
|
|
15
|
+
raise NotImplementedError, "#{self.class} must implement #delete"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def exists?(feature_name)
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #exists?"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def all_features
|
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #all_features"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thread'
|
|
4
|
+
|
|
5
|
+
module Magick
|
|
6
|
+
module Adapters
|
|
7
|
+
class Memory < Base
|
|
8
|
+
def initialize
|
|
9
|
+
@store = {}
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@ttl = {}
|
|
12
|
+
@default_ttl = 3600 # 1 hour default TTL
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get(feature_name, key)
|
|
16
|
+
mutex.synchronize do
|
|
17
|
+
cleanup_expired
|
|
18
|
+
feature_data = store[feature_name.to_s]
|
|
19
|
+
return nil unless feature_data
|
|
20
|
+
|
|
21
|
+
value = feature_data[key.to_s]
|
|
22
|
+
deserialize_value(value)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def set(feature_name, key, value)
|
|
27
|
+
mutex.synchronize do
|
|
28
|
+
feature_name_str = feature_name.to_s
|
|
29
|
+
store[feature_name_str] ||= {}
|
|
30
|
+
store[feature_name_str][key.to_s] = serialize_value(value)
|
|
31
|
+
update_ttl(feature_name_str)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete(feature_name)
|
|
36
|
+
mutex.synchronize do
|
|
37
|
+
feature_name_str = feature_name.to_s
|
|
38
|
+
store.delete(feature_name_str)
|
|
39
|
+
ttl.delete(feature_name_str)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def exists?(feature_name)
|
|
44
|
+
mutex.synchronize do
|
|
45
|
+
cleanup_expired
|
|
46
|
+
store.key?(feature_name.to_s)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def all_features
|
|
51
|
+
mutex.synchronize do
|
|
52
|
+
cleanup_expired
|
|
53
|
+
store.keys
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def clear
|
|
58
|
+
mutex.synchronize do
|
|
59
|
+
@store = {}
|
|
60
|
+
@ttl = {}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def set_ttl(feature_name, seconds)
|
|
65
|
+
mutex.synchronize do
|
|
66
|
+
ttl[feature_name.to_s] = Time.now.to_i + seconds
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
attr_reader :store, :mutex, :ttl, :default_ttl
|
|
73
|
+
|
|
74
|
+
def cleanup_expired
|
|
75
|
+
now = Time.now.to_i
|
|
76
|
+
expired_keys = ttl.select { |_key, expiry| expiry < now }.keys
|
|
77
|
+
expired_keys.each do |key|
|
|
78
|
+
store.delete(key)
|
|
79
|
+
ttl.delete(key)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def update_ttl(feature_name)
|
|
84
|
+
ttl[feature_name] = Time.now.to_i + default_ttl
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def serialize_value(value)
|
|
88
|
+
case value
|
|
89
|
+
when Hash, Array
|
|
90
|
+
Marshal.dump(value)
|
|
91
|
+
else
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def deserialize_value(value)
|
|
97
|
+
return nil if value.nil?
|
|
98
|
+
|
|
99
|
+
case value
|
|
100
|
+
when String
|
|
101
|
+
# Try to unmarshal if it's a serialized hash/array
|
|
102
|
+
begin
|
|
103
|
+
Marshal.load(value)
|
|
104
|
+
rescue StandardError
|
|
105
|
+
value
|
|
106
|
+
end
|
|
107
|
+
else
|
|
108
|
+
value
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Adapters
|
|
5
|
+
class Redis < Base
|
|
6
|
+
def initialize(redis_client = nil)
|
|
7
|
+
@redis = redis_client || default_redis_client
|
|
8
|
+
@namespace = 'magick:features'
|
|
9
|
+
raise AdapterError, 'Redis client is required' unless @redis
|
|
10
|
+
rescue StandardError => e
|
|
11
|
+
raise AdapterError, "Failed to initialize Redis adapter: #{e.message}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get(feature_name, key)
|
|
15
|
+
value = redis.hget(key_for(feature_name), key.to_s)
|
|
16
|
+
deserialize_value(value)
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
raise AdapterError, "Failed to get from Redis: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def set(feature_name, key, value)
|
|
22
|
+
redis.hset(key_for(feature_name), key.to_s, serialize_value(value))
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
raise AdapterError, "Failed to set in Redis: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def delete(feature_name)
|
|
28
|
+
redis.del(key_for(feature_name))
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
raise AdapterError, "Failed to delete from Redis: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def exists?(feature_name)
|
|
34
|
+
redis.exists?(key_for(feature_name))
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
raise AdapterError, "Failed to check existence in Redis: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def all_features
|
|
40
|
+
pattern = "#{namespace}:*"
|
|
41
|
+
keys = redis.keys(pattern)
|
|
42
|
+
keys.map { |key| key.sub("#{namespace}:", '') }
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
raise AdapterError, "Failed to get all features from Redis: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :redis, :namespace
|
|
50
|
+
|
|
51
|
+
def key_for(feature_name)
|
|
52
|
+
"#{namespace}:#{feature_name}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def default_redis_client
|
|
56
|
+
return nil unless defined?(Redis)
|
|
57
|
+
|
|
58
|
+
require 'redis'
|
|
59
|
+
::Redis.new
|
|
60
|
+
rescue StandardError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def serialize_value(value)
|
|
65
|
+
case value
|
|
66
|
+
when Hash, Array
|
|
67
|
+
Marshal.dump(value)
|
|
68
|
+
when true
|
|
69
|
+
'true'
|
|
70
|
+
when false
|
|
71
|
+
'false'
|
|
72
|
+
else
|
|
73
|
+
value.to_s
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def deserialize_value(value)
|
|
78
|
+
return nil if value.nil?
|
|
79
|
+
|
|
80
|
+
# Try to unmarshal if it's a serialized hash/array
|
|
81
|
+
if value.is_a?(String) && value.start_with?("\x04\x08")
|
|
82
|
+
begin
|
|
83
|
+
Marshal.load(value)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
elsif value == 'true'
|
|
88
|
+
true
|
|
89
|
+
elsif value == 'false'
|
|
90
|
+
false
|
|
91
|
+
else
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|