magick-feature-flags 0.9.23 → 0.9.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +213 -5
- data/lib/generators/magick/active_record/active_record_generator.rb +44 -0
- data/lib/generators/magick/active_record/templates/create_magick_features.rb +20 -0
- data/lib/magick/adapters/active_record.rb +267 -0
- data/lib/magick/adapters/registry.rb +106 -33
- data/lib/magick/admin_ui/config/routes.rb +13 -0
- data/lib/magick/admin_ui/engine.rb +98 -0
- data/lib/magick/admin_ui/helpers.rb +33 -0
- data/lib/magick/admin_ui/routes.rb +17 -0
- data/lib/magick/admin_ui.rb +42 -0
- data/lib/magick/config.rb +115 -9
- data/lib/magick/documentation.rb +153 -0
- data/lib/magick/feature.rb +62 -23
- data/lib/magick/rails/railtie.rb +18 -7
- data/lib/magick/version.rb +1 -1
- data/lib/magick.rb +9 -0
- metadata +46 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 955281614bf30144cc9ab808bc425aa3ef9a7217f54fda216fbc1666e159a282
|
|
4
|
+
data.tar.gz: 4dcfafc1e22d19472464da79de5765b02a4f9e0540e9d2ff16ee7527110d2924
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1dbbd4877f1a84a8770f5b9175920ca5060e7b26b7acf23f1006094a33287ff41525ebef847ee0ebf50f22e94f14f2c20755621f503df9941cf2b8649b82e110
|
|
7
|
+
data.tar.gz: 0be714620546fc53ba039b417b0b6d85627346da8bb60acd6432f6e058ab650f20c3b0ec70f367972056dd0a7c1731244da5732bb0a572ae0047d6d1a46d535e
|
data/README.md
CHANGED
|
@@ -10,7 +10,7 @@ A performant and memory-efficient feature toggle gem for Ruby and Rails applicat
|
|
|
10
10
|
- **Rails Integration**: Seamless integration with Rails, including request store caching
|
|
11
11
|
- **DSL Support**: Define features in a Ruby DSL file (`config/features.rb`)
|
|
12
12
|
- **Thread-Safe**: All operations are thread-safe for concurrent access
|
|
13
|
-
- **Performance**:
|
|
13
|
+
- **Performance**: Lightning-fast feature checks with async metrics recording and memory-first caching strategy
|
|
14
14
|
- **Advanced Features**: Circuit breaker, audit logging, performance metrics, versioning, and more
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
@@ -33,7 +33,7 @@ Or install it yourself as:
|
|
|
33
33
|
$ gem install magick
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
##
|
|
36
|
+
## Setup
|
|
37
37
|
|
|
38
38
|
After adding the gem to your Gemfile and running `bundle install`, generate the configuration file:
|
|
39
39
|
|
|
@@ -43,6 +43,22 @@ rails generate magick:install
|
|
|
43
43
|
|
|
44
44
|
This will create `config/initializers/magick.rb` with a basic configuration.
|
|
45
45
|
|
|
46
|
+
### ActiveRecord Adapter (Optional)
|
|
47
|
+
|
|
48
|
+
If you want to use ActiveRecord as a persistent storage backend, generate the migration:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
rails generate magick:active_record
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This will create a migration file that creates the `magick_features` table. Then run:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
rails db:migrate
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Note:** The ActiveRecord adapter is optional and only needed if you want database-backed feature flags. The gem works perfectly fine with just the memory adapter or Redis adapter.
|
|
61
|
+
|
|
46
62
|
## Configuration
|
|
47
63
|
|
|
48
64
|
### Basic Configuration
|
|
@@ -52,7 +68,8 @@ The generator creates `config/initializers/magick.rb` with sensible defaults. Yo
|
|
|
52
68
|
```ruby
|
|
53
69
|
Magick.configure do
|
|
54
70
|
# Configure Redis (optional)
|
|
55
|
-
|
|
71
|
+
# Use database 1 by default to avoid conflicts with Rails cache (which uses DB 0)
|
|
72
|
+
redis url: ENV['REDIS_URL'], db: 1
|
|
56
73
|
|
|
57
74
|
# Enable features
|
|
58
75
|
performance_metrics enabled: true
|
|
@@ -73,7 +90,10 @@ Magick.configure do
|
|
|
73
90
|
memory_ttl 7200 # 2 hours
|
|
74
91
|
|
|
75
92
|
# Redis configuration
|
|
76
|
-
|
|
93
|
+
# Use separate database (DB 1) to avoid conflicts with Rails cache (DB 0)
|
|
94
|
+
# This ensures feature toggles persist even when Rails cache is cleared
|
|
95
|
+
redis url: ENV['REDIS_URL'], namespace: 'magick:features', db: 1
|
|
96
|
+
# Or include database in URL: redis url: 'redis://localhost:6379/1'
|
|
77
97
|
|
|
78
98
|
# Circuit breaker settings
|
|
79
99
|
circuit_breaker threshold: 5, timeout: 60
|
|
@@ -91,6 +111,9 @@ Magick.configure do
|
|
|
91
111
|
audit_log enabled: true
|
|
92
112
|
versioning enabled: true
|
|
93
113
|
warn_on_deprecated enabled: true
|
|
114
|
+
|
|
115
|
+
# Enable Admin UI (optional)
|
|
116
|
+
admin_ui enabled: true
|
|
94
117
|
end
|
|
95
118
|
```
|
|
96
119
|
|
|
@@ -383,7 +406,9 @@ Magick.configure do
|
|
|
383
406
|
end
|
|
384
407
|
```
|
|
385
408
|
|
|
386
|
-
**
|
|
409
|
+
**Performance:** Metrics are recorded asynchronously in a background thread, ensuring zero overhead on feature checks. The `enabled?` method remains lightning-fast even with metrics enabled.
|
|
410
|
+
|
|
411
|
+
**Note:** When `redis_tracking: true` is set, usage counts are persisted to Redis and aggregated across all processes, giving you total usage statistics. Metrics are automatically flushed in batches to minimize Redis overhead.
|
|
387
412
|
|
|
388
413
|
#### Audit Logging
|
|
389
414
|
|
|
@@ -424,6 +449,189 @@ With Redis configured:
|
|
|
424
449
|
- ✅ Persistent storage across restarts
|
|
425
450
|
- ✅ Zero Redis calls on feature checks (only memory lookups)
|
|
426
451
|
- ✅ Automatic cache invalidation when features change in any process
|
|
452
|
+
- ✅ **Isolated from Rails cache** - Use `db: 1` to store feature toggles in a separate Redis database, ensuring they persist even when Rails cache is cleared
|
|
453
|
+
|
|
454
|
+
**Important:** By default, Magick uses Redis database 1 to avoid conflicts with Rails cache (which typically uses database 0). This ensures that clearing Rails cache (`Rails.cache.clear`) won't affect your feature toggle states.
|
|
455
|
+
|
|
456
|
+
#### ActiveRecord Adapter (Optional)
|
|
457
|
+
|
|
458
|
+
The ActiveRecord adapter provides database-backed persistent storage for feature flags. It's useful when you want to:
|
|
459
|
+
- Store feature flags in your application database
|
|
460
|
+
- Use ActiveRecord models for feature management
|
|
461
|
+
- Have a fallback storage layer
|
|
462
|
+
- Work with PostgreSQL, MySQL, SQLite, or any ActiveRecord-supported database
|
|
463
|
+
|
|
464
|
+
**Setup:**
|
|
465
|
+
|
|
466
|
+
1. Generate the migration:
|
|
467
|
+
```bash
|
|
468
|
+
rails generate magick:active_record
|
|
469
|
+
rails db:migrate
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
**With UUID primary keys:**
|
|
473
|
+
```bash
|
|
474
|
+
rails generate magick:active_record --uuid
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
2. Configure in `config/initializers/magick.rb`:
|
|
478
|
+
```ruby
|
|
479
|
+
Magick.configure do
|
|
480
|
+
active_record # Uses default MagickFeature model
|
|
481
|
+
# Or specify a custom model:
|
|
482
|
+
# active_record model_class: YourCustomModel
|
|
483
|
+
end
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
The adapter automatically creates the `magick_features` table if it doesn't exist, but using the generator is recommended for production applications.
|
|
487
|
+
|
|
488
|
+
**PostgreSQL Support:**
|
|
489
|
+
|
|
490
|
+
The generator automatically detects PostgreSQL and uses `jsonb` for the `data` column, providing:
|
|
491
|
+
- Better performance with native JSON queries
|
|
492
|
+
- Native JSON indexing and querying capabilities
|
|
493
|
+
- Type-safe JSON storage
|
|
494
|
+
|
|
495
|
+
For other databases (MySQL, SQLite, etc.), it uses `text` with serialized JSON.
|
|
496
|
+
|
|
497
|
+
**UUID Primary Keys:**
|
|
498
|
+
|
|
499
|
+
When using the `--uuid` flag:
|
|
500
|
+
- Creates table with `id: :uuid` instead of integer primary key
|
|
501
|
+
- Enables `pgcrypto` extension for PostgreSQL (required for UUID generation)
|
|
502
|
+
- Works with other databases using their native UUID support
|
|
503
|
+
|
|
504
|
+
**Note:** The ActiveRecord adapter works as a fallback in the adapter chain: Memory → Redis → ActiveRecord. It's automatically included if ActiveRecord is available and configured.
|
|
505
|
+
|
|
506
|
+
**Adapter Chain:**
|
|
507
|
+
|
|
508
|
+
The adapter registry uses a fallback strategy:
|
|
509
|
+
1. **Memory Adapter** (first) - Fast, in-memory lookups
|
|
510
|
+
2. **Redis Adapter** (second) - Persistent, distributed storage
|
|
511
|
+
3. **ActiveRecord Adapter** (third) - Database-backed fallback
|
|
512
|
+
|
|
513
|
+
When a feature is requested:
|
|
514
|
+
- First checks memory cache (fastest)
|
|
515
|
+
- Falls back to Redis if not in memory
|
|
516
|
+
- Falls back to ActiveRecord if Redis is unavailable or returns nil
|
|
517
|
+
- Updates all adapters when features are modified
|
|
518
|
+
|
|
519
|
+
This ensures maximum performance while maintaining persistence and reliability.
|
|
520
|
+
|
|
521
|
+
### Admin UI
|
|
522
|
+
|
|
523
|
+
Magick includes a web-based Admin UI for managing feature flags. It's a Rails Engine that provides a user-friendly interface for viewing, enabling, disabling, and configuring features.
|
|
524
|
+
|
|
525
|
+
**Setup:**
|
|
526
|
+
|
|
527
|
+
1. Enable Admin UI in `config/initializers/magick.rb`:
|
|
528
|
+
|
|
529
|
+
```ruby
|
|
530
|
+
Magick.configure do
|
|
531
|
+
admin_ui enabled: true
|
|
532
|
+
end
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
2. Configure roles (optional) for targeting management:
|
|
536
|
+
|
|
537
|
+
```ruby
|
|
538
|
+
Magick::AdminUI.configure do |config|
|
|
539
|
+
config.available_roles = ['admin', 'user', 'manager', 'guest']
|
|
540
|
+
end
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
3. Mount the engine in `config/routes.rb`:
|
|
544
|
+
|
|
545
|
+
```ruby
|
|
546
|
+
Rails.application.routes.draw do
|
|
547
|
+
# ... your other routes ...
|
|
548
|
+
|
|
549
|
+
# With authentication (recommended for production)
|
|
550
|
+
authenticate :admin_user do
|
|
551
|
+
mount Magick::AdminUI::Engine, at: '/magick'
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Or without authentication (development only)
|
|
555
|
+
# mount Magick::AdminUI::Engine, at: '/magick'
|
|
556
|
+
end
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
**Access:**
|
|
560
|
+
|
|
561
|
+
Once mounted, visit `/magick` in your browser to access the Admin UI.
|
|
562
|
+
|
|
563
|
+
**Features:**
|
|
564
|
+
|
|
565
|
+
- **Feature List**: View all registered features with their current status, type, and description
|
|
566
|
+
- **Feature Details**: View detailed information about each feature including:
|
|
567
|
+
- Current value/status
|
|
568
|
+
- Targeting rules (users, groups, roles, percentages, etc.)
|
|
569
|
+
- Performance statistics (usage count, average duration)
|
|
570
|
+
- Feature metadata (type, default value, dependencies)
|
|
571
|
+
- **Enable/Disable**: Quickly enable or disable features globally
|
|
572
|
+
- **Targeting Management**: Configure targeting rules through a user-friendly interface:
|
|
573
|
+
- **Role Targeting**: Select roles from a configured list (checkboxes)
|
|
574
|
+
- **User Targeting**: Enter user IDs (comma-separated)
|
|
575
|
+
- **Visual Display**: See all active targeting rules with badges
|
|
576
|
+
- **Edit Features**: Update feature values (boolean, string, number) directly from the UI
|
|
577
|
+
- **Statistics**: View performance metrics and usage statistics for each feature
|
|
578
|
+
|
|
579
|
+
**Targeting Management:**
|
|
580
|
+
|
|
581
|
+
The Admin UI provides a comprehensive targeting interface:
|
|
582
|
+
|
|
583
|
+
1. **Role Targeting**:
|
|
584
|
+
- Configure available roles via `Magick::AdminUI.configure`
|
|
585
|
+
- Select multiple roles using checkboxes
|
|
586
|
+
- Roles are automatically added/removed when checkboxes are toggled
|
|
587
|
+
|
|
588
|
+
2. **User Targeting**:
|
|
589
|
+
- Enter user IDs as comma-separated values (e.g., `123, 456, 789`)
|
|
590
|
+
- Add or remove users dynamically
|
|
591
|
+
- Clear all user targeting by leaving the field empty
|
|
592
|
+
|
|
593
|
+
3. **Visual Feedback**:
|
|
594
|
+
- All targeting rules are displayed as badges in the feature details view
|
|
595
|
+
- Easy to see which roles/users have access to each feature
|
|
596
|
+
|
|
597
|
+
**Routes:**
|
|
598
|
+
|
|
599
|
+
The Admin UI provides the following routes:
|
|
600
|
+
|
|
601
|
+
- `GET /magick` - Feature list (index)
|
|
602
|
+
- `GET /magick/features/:id` - Feature details
|
|
603
|
+
- `GET /magick/features/:id/edit` - Edit feature
|
|
604
|
+
- `PUT /magick/features/:id` - Update feature value
|
|
605
|
+
- `PUT /magick/features/:id/enable` - Enable feature globally
|
|
606
|
+
- `PUT /magick/features/:id/disable` - Disable feature globally
|
|
607
|
+
- `PUT /magick/features/:id/enable_for_user` - Enable feature for specific user
|
|
608
|
+
- `PUT /magick/features/:id/enable_for_role` - Enable feature for specific role
|
|
609
|
+
- `PUT /magick/features/:id/disable_for_role` - Disable feature for specific role
|
|
610
|
+
- `PUT /magick/features/:id/update_targeting` - Update targeting rules (roles and users)
|
|
611
|
+
- `GET /magick/stats/:id` - View feature statistics
|
|
612
|
+
|
|
613
|
+
**Security:**
|
|
614
|
+
|
|
615
|
+
The Admin UI is a basic Rails Engine without built-in authentication. **You should add authentication/authorization** before mounting it in production. For example:
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
# config/routes.rb
|
|
619
|
+
Rails.application.routes.draw do
|
|
620
|
+
# Using Devise
|
|
621
|
+
authenticate :admin_user do
|
|
622
|
+
mount Magick::AdminUI::Engine, at: '/magick'
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# Or using session-based authentication
|
|
626
|
+
constraints(->(request) { request.session[:user_id].present? && request.session[:admin] }) do
|
|
627
|
+
mount Magick::AdminUI::Engine, at: '/magick'
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
Or use a before_action in your ApplicationController if you mount it at the application level.
|
|
633
|
+
|
|
634
|
+
**Note:** The Admin UI is optional and only loaded when explicitly enabled in configuration. It requires Rails to be available.
|
|
427
635
|
|
|
428
636
|
### Feature Types
|
|
429
637
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Generators
|
|
5
|
+
class ActiveRecordGenerator < Rails::Generators::Base
|
|
6
|
+
include Rails::Generators::Migration
|
|
7
|
+
|
|
8
|
+
source_root File.expand_path('templates', __dir__)
|
|
9
|
+
|
|
10
|
+
desc 'Creates a migration for Magick feature flags table (ActiveRecord adapter)'
|
|
11
|
+
class_option :uuid, type: :boolean, default: false, desc: 'Use UUID as primary key'
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(dirname)
|
|
14
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_migration
|
|
18
|
+
unless defined?(::ActiveRecord::Base)
|
|
19
|
+
say 'ActiveRecord is not available. This generator requires ActiveRecord.', :red
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
migration_number = self.class.next_migration_number('db/migrate')
|
|
24
|
+
@use_uuid = options[:uuid]
|
|
25
|
+
@is_postgresql = postgresql?
|
|
26
|
+
template 'create_magick_features.rb', "db/migrate/#{migration_number}_create_magick_features.rb"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def postgresql?
|
|
32
|
+
return false unless defined?(::ActiveRecord::Base)
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
36
|
+
adapter == 'postgresql' || adapter == 'postgis'
|
|
37
|
+
rescue StandardError
|
|
38
|
+
# If we can't connect, check database.yml or default to false
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateMagickFeatures < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
<% if @use_uuid -%>
|
|
6
|
+
enable_extension 'pgcrypto' if adapter_name == 'PostgreSQL'
|
|
7
|
+
create_table :magick_features, id: :uuid do |t|
|
|
8
|
+
<% else -%>
|
|
9
|
+
create_table :magick_features do |t|
|
|
10
|
+
<% end -%>
|
|
11
|
+
t.string :feature_name, null: false, index: { unique: true }
|
|
12
|
+
<% if @is_postgresql -%>
|
|
13
|
+
t.jsonb :data
|
|
14
|
+
<% else -%>
|
|
15
|
+
t.text :data
|
|
16
|
+
<% end -%>
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Adapters
|
|
5
|
+
class ActiveRecord < Base
|
|
6
|
+
@table_created_mutex = Mutex.new
|
|
7
|
+
@table_created = false
|
|
8
|
+
|
|
9
|
+
def initialize(model_class: nil)
|
|
10
|
+
@model_class = model_class || default_model_class
|
|
11
|
+
ensure_table_exists
|
|
12
|
+
rescue StandardError => e
|
|
13
|
+
raise AdapterError, "Failed to initialize ActiveRecord adapter: #{e.message}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def get(feature_name, key)
|
|
17
|
+
ensure_table_exists unless @model_class.table_exists?
|
|
18
|
+
feature_name_str = feature_name.to_s
|
|
19
|
+
record = @model_class.find_by(feature_name: feature_name_str)
|
|
20
|
+
return nil unless record
|
|
21
|
+
|
|
22
|
+
# Handle both Hash (from serialize) and Hash/JSON (from attribute :json)
|
|
23
|
+
data = record.data || {}
|
|
24
|
+
value = data.is_a?(Hash) ? data[key.to_s] : nil
|
|
25
|
+
deserialize_value(value)
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
# If table doesn't exist, try to create it and retry once
|
|
28
|
+
if e.message.include?('no such table') || e.message.include?("doesn't exist")
|
|
29
|
+
ensure_table_exists
|
|
30
|
+
retry
|
|
31
|
+
end
|
|
32
|
+
raise AdapterError, "Failed to get from ActiveRecord: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def set(feature_name, key, value)
|
|
36
|
+
ensure_table_exists unless @model_class.table_exists?
|
|
37
|
+
feature_name_str = feature_name.to_s
|
|
38
|
+
retries = 5
|
|
39
|
+
begin
|
|
40
|
+
record = @model_class.find_or_initialize_by(feature_name: feature_name_str)
|
|
41
|
+
# Ensure data is a Hash (works for both serialize and attribute :json)
|
|
42
|
+
data = record.data || {}
|
|
43
|
+
data = {} unless data.is_a?(Hash)
|
|
44
|
+
data[key.to_s] = serialize_value(value)
|
|
45
|
+
record.data = data
|
|
46
|
+
# Use Time.now if Time.current is not available (for non-Rails environments)
|
|
47
|
+
record.updated_at = defined?(Time.current) ? Time.current : Time.now
|
|
48
|
+
record.save!
|
|
49
|
+
rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
|
|
50
|
+
# SQLite busy/locked errors - retry with exponential backoff
|
|
51
|
+
if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout') || e.message.include?('no such table')) && retries > 0
|
|
52
|
+
retries -= 1
|
|
53
|
+
# If it's a "no such table" error, ensure table exists
|
|
54
|
+
if e.message.include?('no such table')
|
|
55
|
+
ensure_table_exists
|
|
56
|
+
end
|
|
57
|
+
sleep(0.01 * (6 - retries)) # Exponential backoff: 0.01, 0.02, 0.03, 0.04, 0.05
|
|
58
|
+
retry
|
|
59
|
+
end
|
|
60
|
+
raise AdapterError, "Failed to set in ActiveRecord: #{e.message}"
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
# If table doesn't exist, try to create it and retry once
|
|
63
|
+
if (e.message.include?('no such table') || e.message.include?("doesn't exist")) && retries > 0
|
|
64
|
+
retries -= 1
|
|
65
|
+
ensure_table_exists
|
|
66
|
+
sleep(0.01)
|
|
67
|
+
retry
|
|
68
|
+
end
|
|
69
|
+
raise AdapterError, "Failed to set in ActiveRecord: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def delete(feature_name)
|
|
74
|
+
ensure_table_exists unless @model_class.table_exists?
|
|
75
|
+
feature_name_str = feature_name.to_s
|
|
76
|
+
retries = 5
|
|
77
|
+
begin
|
|
78
|
+
@model_class.where(feature_name: feature_name_str).destroy_all
|
|
79
|
+
rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
|
|
80
|
+
# SQLite busy/locked errors - retry with exponential backoff
|
|
81
|
+
if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout') || e.message.include?('no such table')) && retries > 0
|
|
82
|
+
retries -= 1
|
|
83
|
+
# If it's a "no such table" error, ensure table exists
|
|
84
|
+
if e.message.include?('no such table')
|
|
85
|
+
ensure_table_exists
|
|
86
|
+
end
|
|
87
|
+
sleep(0.01 * (6 - retries)) # Exponential backoff: 0.01, 0.02, 0.03, 0.04, 0.05
|
|
88
|
+
retry
|
|
89
|
+
end
|
|
90
|
+
raise AdapterError, "Failed to delete from ActiveRecord: #{e.message}"
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
# If table doesn't exist, try to create it and retry once
|
|
93
|
+
if (e.message.include?('no such table') || e.message.include?("doesn't exist")) && retries > 0
|
|
94
|
+
retries -= 1
|
|
95
|
+
ensure_table_exists
|
|
96
|
+
sleep(0.01)
|
|
97
|
+
retry
|
|
98
|
+
end
|
|
99
|
+
raise AdapterError, "Failed to delete from ActiveRecord: #{e.message}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def exists?(feature_name)
|
|
104
|
+
@model_class.exists?(feature_name: feature_name.to_s)
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
raise AdapterError, "Failed to check existence in ActiveRecord: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def all_features
|
|
110
|
+
@model_class.pluck(:feature_name).uniq
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
raise AdapterError, "Failed to get all features from ActiveRecord: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def default_model_class
|
|
118
|
+
return MagickFeature if defined?(MagickFeature)
|
|
119
|
+
|
|
120
|
+
# Create model class if it doesn't exist
|
|
121
|
+
create_model_class
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def create_model_class
|
|
125
|
+
# Define the model class dynamically
|
|
126
|
+
# Use ::ActiveRecord::VERSION to access from global namespace
|
|
127
|
+
ar_major = ::ActiveRecord::VERSION::MAJOR
|
|
128
|
+
ar_minor = ::ActiveRecord::VERSION::MINOR
|
|
129
|
+
use_json = ar_major >= 8 || (ar_major == 7 && ar_minor >= 1)
|
|
130
|
+
|
|
131
|
+
Object.const_set('MagickFeature', Class.new(::ActiveRecord::Base) do
|
|
132
|
+
self.table_name = 'magick_features'
|
|
133
|
+
|
|
134
|
+
# ActiveRecord 8.1 changed serialize signature - it now only accepts one argument
|
|
135
|
+
# Use attribute :data, :json for ActiveRecord 7.1+ (including 8.1)
|
|
136
|
+
# Fall back to serialize for older versions
|
|
137
|
+
if use_json
|
|
138
|
+
# ActiveRecord 7.1+ and 8.x use attribute with type
|
|
139
|
+
attribute :data, :json, default: {}
|
|
140
|
+
else
|
|
141
|
+
# Older ActiveRecord versions use serialize
|
|
142
|
+
serialize :data, Hash
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.table_exists?
|
|
146
|
+
connection.table_exists?(table_name)
|
|
147
|
+
end
|
|
148
|
+
end)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def ensure_table_exists
|
|
152
|
+
return if @model_class.table_exists?
|
|
153
|
+
|
|
154
|
+
# Use a non-blocking mutex to prevent deadlocks
|
|
155
|
+
mutex = self.class.instance_variable_get(:@table_created_mutex)
|
|
156
|
+
if mutex.try_lock
|
|
157
|
+
begin
|
|
158
|
+
# Double-check after acquiring lock
|
|
159
|
+
return if @model_class.table_exists?
|
|
160
|
+
|
|
161
|
+
create_table
|
|
162
|
+
self.class.instance_variable_set(:@table_created, true)
|
|
163
|
+
ensure
|
|
164
|
+
mutex.unlock
|
|
165
|
+
end
|
|
166
|
+
else
|
|
167
|
+
# Another thread is creating the table, wait for it to complete
|
|
168
|
+
# Use a longer timeout with exponential backoff to avoid hanging
|
|
169
|
+
20.times do |i|
|
|
170
|
+
sleep(0.01 * (i + 1)) # Exponential backoff: 0.01, 0.02, 0.03, ...
|
|
171
|
+
return if @model_class.table_exists?
|
|
172
|
+
end
|
|
173
|
+
# If we still don't have the table, try one more time
|
|
174
|
+
unless @model_class.table_exists?
|
|
175
|
+
# Last attempt: try to acquire lock and create
|
|
176
|
+
if mutex.try_lock
|
|
177
|
+
begin
|
|
178
|
+
return if @model_class.table_exists?
|
|
179
|
+
create_table
|
|
180
|
+
self.class.instance_variable_set(:@table_created, true)
|
|
181
|
+
ensure
|
|
182
|
+
mutex.unlock
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
# Don't raise if table exists now (might have been created by another thread)
|
|
189
|
+
return if @model_class.table_exists?
|
|
190
|
+
raise e
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def create_table
|
|
194
|
+
connection = @model_class.connection
|
|
195
|
+
return if connection.table_exists?('magick_features')
|
|
196
|
+
|
|
197
|
+
connection.create_table :magick_features do |t|
|
|
198
|
+
t.string :feature_name, null: false, index: { unique: true }
|
|
199
|
+
t.text :data
|
|
200
|
+
t.timestamps
|
|
201
|
+
end
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
# Table might already exist or migration might be needed
|
|
204
|
+
# Check if table exists now (might have been created by another thread)
|
|
205
|
+
return if connection.table_exists?('magick_features')
|
|
206
|
+
|
|
207
|
+
warn "Magick: Could not create magick_features table: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
208
|
+
raise e if defined?(Rails) && Rails.env.test?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def serialize_value(value)
|
|
212
|
+
# For ActiveRecord 8.1+ with attribute :json, we can store booleans as-is
|
|
213
|
+
# For older versions with serialize, we convert to strings
|
|
214
|
+
ar_major = ::ActiveRecord::VERSION::MAJOR
|
|
215
|
+
ar_minor = ::ActiveRecord::VERSION::MINOR
|
|
216
|
+
use_json = ar_major >= 8 || (ar_major == 7 && ar_minor >= 1)
|
|
217
|
+
|
|
218
|
+
case value
|
|
219
|
+
when Hash, Array
|
|
220
|
+
value
|
|
221
|
+
when true
|
|
222
|
+
use_json ? true : 'true'
|
|
223
|
+
when false
|
|
224
|
+
use_json ? false : 'false'
|
|
225
|
+
else
|
|
226
|
+
value
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def deserialize_value(value)
|
|
231
|
+
return nil if value.nil?
|
|
232
|
+
|
|
233
|
+
# For ActiveRecord 8.1+ with attribute :json, booleans are already booleans
|
|
234
|
+
# For older versions with serialize, we convert from strings
|
|
235
|
+
case value
|
|
236
|
+
when Hash
|
|
237
|
+
# JSON serialization converts symbol keys to strings
|
|
238
|
+
# Convert string keys back to symbols for consistency with input
|
|
239
|
+
symbolize_hash_keys(value)
|
|
240
|
+
when Array
|
|
241
|
+
# Recursively process array elements
|
|
242
|
+
value.map { |v| v.is_a?(Hash) ? symbolize_hash_keys(v) : v }
|
|
243
|
+
when 'true'
|
|
244
|
+
# String 'true' from older serialize - convert to boolean
|
|
245
|
+
true
|
|
246
|
+
when 'false'
|
|
247
|
+
# String 'false' from older serialize - convert to boolean
|
|
248
|
+
false
|
|
249
|
+
when true, false
|
|
250
|
+
# Already a boolean (from JSON attribute)
|
|
251
|
+
value
|
|
252
|
+
else
|
|
253
|
+
value
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def symbolize_hash_keys(hash)
|
|
258
|
+
return hash unless hash.is_a?(Hash)
|
|
259
|
+
|
|
260
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
261
|
+
key = k.is_a?(String) ? k.to_sym : k
|
|
262
|
+
result[key] = v.is_a?(Hash) ? symbolize_hash_keys(v) : (v.is_a?(Array) ? v.map { |item| item.is_a?(Hash) ? symbolize_hash_keys(item) : item } : v)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|