solid_queue_autoscaler 1.0.8 → 1.0.10
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 +29 -0
- data/README.md +14 -4
- data/lib/generators/solid_queue_autoscaler/migration_generator.rb +180 -4
- data/lib/generators/solid_queue_autoscaler/templates/README +27 -6
- data/lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_events.rb.erb +17 -19
- data/lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_state.rb.erb +9 -9
- data/lib/solid_queue_autoscaler/autoscale_job.rb +10 -33
- data/lib/solid_queue_autoscaler/configuration.rb +6 -0
- data/lib/solid_queue_autoscaler/scale_event.rb +188 -0
- data/lib/solid_queue_autoscaler/scaler.rb +98 -20
- data/lib/solid_queue_autoscaler/version.rb +1 -1
- data/lib/solid_queue_autoscaler.rb +254 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 39531d30eeaa5c53e6c5f4fb01f106b586ebe9b0243be5d7e6ae7748d591c52f
|
|
4
|
+
data.tar.gz: f8a4035f055d66f9ce04d6450653c86cc9ee6cd632145db26e0b8c2c2085a731
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: df8dca7f8a6e75ee7feea851c3120af1fe522e273e7b937d1df02988bc8297dc72094ea272095db490c80ae4d617bb47f15e5adbee26bd36706373f0c020c7cb
|
|
7
|
+
data.tar.gz: aa902d3745ab5992f5698bb130078170217ed389511aba8f6669704fafe1e04a7ab8df7ff2391104af75951d4ce68941f3cf15df0bf3c1c2cae05e1d4d26648e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.10] - 2025-01-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Fixed Dashboard not finding events table in multi-database setups** - `ScaleEvent.default_connection` now correctly uses `SolidQueue::Record.connection` instead of `ActiveRecord::Base.connection`
|
|
14
|
+
- This was causing "Events table not found" errors when using a separate queue database
|
|
15
|
+
|
|
16
|
+
## [1.0.9] - 2025-01-17
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **`verify_setup!` method** - New diagnostic method to verify installation is correct
|
|
20
|
+
- Checks database connection (multi-database aware)
|
|
21
|
+
- Verifies both autoscaler tables exist with correct columns
|
|
22
|
+
- Tests adapter connectivity
|
|
23
|
+
- Returns a `VerificationResult` struct with `ok?`, `tables_exist?`, `cooldowns_shared?` methods
|
|
24
|
+
- Run `SolidQueueAutoscaler.verify_setup!` in Rails console to diagnose issues
|
|
25
|
+
- **`verify_install!` alias** - Alias for `verify_setup!`
|
|
26
|
+
- **`persist_cooldowns` configuration option** - Control whether cooldowns are stored in database (default: true) or in-memory
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **Fixed multi-database migration bug** - Migration generator now correctly handles `migrations_paths` as a string (not just array)
|
|
30
|
+
- Previously, migrations would be placed in a `d/` directory instead of `db/queue_migrate/` due to calling `.first` on a string
|
|
31
|
+
- Migrations now auto-detect the correct directory from your `database.yml` configuration
|
|
32
|
+
- **Removed unreliable `self.connection` override** from migration templates - This didn't work because `SolidQueue::Record` isn't loaded at migration time
|
|
33
|
+
- **Improved migration generator output** - Clearer instructions for single-database vs multi-database setups
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- `verify_setup!` returns `nil` instead of the result object to keep console output clean
|
|
37
|
+
- Migration templates no longer try to override the database connection (rely on Rails native multi-database support instead)
|
|
38
|
+
|
|
10
39
|
## [1.0.8] - 2025-01-17
|
|
11
40
|
|
|
12
41
|
### Added
|
data/README.md
CHANGED
|
@@ -743,19 +743,23 @@ Solid Queue processes queues in order, so listing `autoscaler` first ensures tho
|
|
|
743
743
|
|
|
744
744
|
### Running as a Solid Queue Recurring Job (Recommended)
|
|
745
745
|
|
|
746
|
+
> ⚠️ **IMPORTANT**: The `queue:` setting in `recurring.yml` **overrides** the `config.job_queue` setting!
|
|
747
|
+
> If you omit `queue:` in your recurring.yml, the job will go to the `default` queue, NOT your configured queue.
|
|
748
|
+
> Always ensure your `recurring.yml` queue matches your `config.job_queue` setting.
|
|
749
|
+
|
|
746
750
|
Add to your `config/recurring.yml`:
|
|
747
751
|
|
|
748
752
|
```yaml
|
|
749
753
|
# Single worker configuration
|
|
750
754
|
autoscaler:
|
|
751
755
|
class: SolidQueueAutoscaler::AutoscaleJob
|
|
752
|
-
queue: autoscaler
|
|
756
|
+
queue: autoscaler # ⚠️ REQUIRED: Must match config.job_queue!
|
|
753
757
|
schedule: every 30 seconds
|
|
754
758
|
|
|
755
759
|
# Or for multi-worker: scale all workers
|
|
756
760
|
autoscaler_all:
|
|
757
761
|
class: SolidQueueAutoscaler::AutoscaleJob
|
|
758
|
-
queue: autoscaler
|
|
762
|
+
queue: autoscaler # ⚠️ REQUIRED!
|
|
759
763
|
schedule: every 30 seconds
|
|
760
764
|
args:
|
|
761
765
|
- :all
|
|
@@ -763,19 +767,25 @@ autoscaler_all:
|
|
|
763
767
|
# Or scale specific worker types on different schedules
|
|
764
768
|
autoscaler_critical:
|
|
765
769
|
class: SolidQueueAutoscaler::AutoscaleJob
|
|
766
|
-
queue: autoscaler
|
|
770
|
+
queue: autoscaler # ⚠️ REQUIRED!
|
|
767
771
|
schedule: every 15 seconds
|
|
768
772
|
args:
|
|
769
773
|
- :critical_worker
|
|
770
774
|
|
|
771
775
|
autoscaler_default:
|
|
772
776
|
class: SolidQueueAutoscaler::AutoscaleJob
|
|
773
|
-
queue: autoscaler
|
|
777
|
+
queue: autoscaler # ⚠️ REQUIRED!
|
|
774
778
|
schedule: every 60 seconds
|
|
775
779
|
args:
|
|
776
780
|
- :default_worker
|
|
777
781
|
```
|
|
778
782
|
|
|
783
|
+
> **Note on multiple worker dynos**: SolidQueue's recurring jobs are processed by the **dispatcher** process,
|
|
784
|
+
> not workers. If each of your worker dynos runs its own dispatcher (which is the default on Heroku),
|
|
785
|
+
> each dyno will try to enqueue the recurring job. To prevent duplicate enqueuing:
|
|
786
|
+
> 1. Run a single dedicated dispatcher dyno, OR
|
|
787
|
+
> 2. Configure workers to NOT run the dispatcher (set `dispatchers: []` in their solid_queue.yml)
|
|
788
|
+
|
|
779
789
|
### Running via Rake Tasks
|
|
780
790
|
|
|
781
791
|
```bash
|
|
@@ -10,19 +10,195 @@ module SolidQueueAutoscaler
|
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path('templates', __dir__)
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
class_option :database, type: :string, default: nil,
|
|
14
|
+
desc: 'Specify database for multi-database setups (e.g., --database=queue)'
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
desc 'Creates migrations for SolidQueueAutoscaler tables'
|
|
17
|
+
|
|
18
|
+
def create_migration_files
|
|
19
|
+
detected_config = detect_database_config
|
|
20
|
+
migration_dir = determine_migration_directory(detected_config)
|
|
21
|
+
db_name = effective_database_name(detected_config)
|
|
22
|
+
|
|
23
|
+
# Show what we detected
|
|
24
|
+
print_detection_info(detected_config, migration_dir)
|
|
25
|
+
|
|
26
|
+
# Create state table migration
|
|
16
27
|
migration_template 'create_solid_queue_autoscaler_state.rb.erb',
|
|
17
|
-
|
|
28
|
+
"#{migration_dir}/create_solid_queue_autoscaler_state.rb"
|
|
29
|
+
|
|
30
|
+
# Create events table migration
|
|
31
|
+
migration_template 'create_solid_queue_autoscaler_events.rb.erb',
|
|
32
|
+
"#{migration_dir}/create_solid_queue_autoscaler_events.rb"
|
|
33
|
+
|
|
34
|
+
print_instructions(migration_dir, db_name)
|
|
18
35
|
end
|
|
19
36
|
|
|
20
37
|
private
|
|
21
38
|
|
|
39
|
+
# Detect database configuration for SolidQueue.
|
|
40
|
+
# Returns a hash with :database_name, :migrations_path, :is_multi_db.
|
|
41
|
+
def detect_database_config
|
|
42
|
+
result = { database_name: nil, migrations_path: nil, is_multi_db: false }
|
|
43
|
+
|
|
44
|
+
# Check if Rails database config is available
|
|
45
|
+
return result unless defined?(Rails) && Rails.application
|
|
46
|
+
|
|
47
|
+
db_configs = Rails.application.config.database_configuration
|
|
48
|
+
env_config = db_configs[Rails.env.to_s] || {}
|
|
49
|
+
|
|
50
|
+
# Look for a 'queue' database configuration (Rails multi-DB naming convention).
|
|
51
|
+
# SolidQueue typically uses 'queue' as the database name.
|
|
52
|
+
queue_config = find_queue_database_config(env_config)
|
|
53
|
+
|
|
54
|
+
if queue_config
|
|
55
|
+
result[:is_multi_db] = true
|
|
56
|
+
result[:database_name] = queue_config[:name]
|
|
57
|
+
result[:migrations_path] = queue_config[:migrations_path]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
result
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
say ' (Could not detect database config: ' + e.message + ')', :yellow
|
|
63
|
+
{ database_name: nil, migrations_path: nil, is_multi_db: false }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Find the queue database configuration from environment config.
|
|
67
|
+
def find_queue_database_config(env_config)
|
|
68
|
+
# Rails 7+ multi-database format: { 'primary' => {...}, 'queue' => {...} }
|
|
69
|
+
# Look for common queue database names.
|
|
70
|
+
queue_db_names = %w[queue solid_queue queue_database]
|
|
71
|
+
|
|
72
|
+
queue_db_names.each do |db_name|
|
|
73
|
+
if env_config[db_name].is_a?(Hash)
|
|
74
|
+
config = env_config[db_name]
|
|
75
|
+
migrations_path = normalize_migrations_path(config['migrations_paths'], db_name)
|
|
76
|
+
return { name: db_name, migrations_path: migrations_path }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if any database config has migrations_paths containing 'queue'.
|
|
81
|
+
env_config.each do |name, config|
|
|
82
|
+
next unless config.is_a?(Hash)
|
|
83
|
+
next if name == 'primary' # Skip primary database
|
|
84
|
+
|
|
85
|
+
migrations_paths = config['migrations_paths']
|
|
86
|
+
path_str = normalize_migrations_path(migrations_paths, name)
|
|
87
|
+
if path_str.include?('queue')
|
|
88
|
+
return { name: name, migrations_path: path_str }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Normalize migrations_paths which can be a string or array.
|
|
96
|
+
def normalize_migrations_path(migrations_paths, db_name)
|
|
97
|
+
case migrations_paths
|
|
98
|
+
when String
|
|
99
|
+
migrations_paths
|
|
100
|
+
when Array
|
|
101
|
+
migrations_paths.first || "db/#{db_name}_migrate"
|
|
102
|
+
else
|
|
103
|
+
"db/#{db_name}_migrate"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Determine the migration directory to use.
|
|
108
|
+
def determine_migration_directory(detected_config)
|
|
109
|
+
# 1. Explicit --database option takes precedence
|
|
110
|
+
if options[:database]
|
|
111
|
+
return 'db/' + options[:database] + '_migrate'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 2. If we detected a queue database with migrations_path, use it
|
|
115
|
+
if detected_config[:is_multi_db] && detected_config[:migrations_path]
|
|
116
|
+
return detected_config[:migrations_path]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# 3. Default to standard migrate directory
|
|
120
|
+
'db/migrate'
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Get the effective database name for running migrations.
|
|
124
|
+
def effective_database_name(detected_config)
|
|
125
|
+
return options[:database] if options[:database]
|
|
126
|
+
return detected_config[:database_name] if detected_config[:is_multi_db]
|
|
127
|
+
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def print_detection_info(detected_config, migration_dir)
|
|
132
|
+
say ''
|
|
133
|
+
|
|
134
|
+
if options[:database]
|
|
135
|
+
say '📁 Using specified database: ' + options[:database], :green
|
|
136
|
+
elsif detected_config[:is_multi_db]
|
|
137
|
+
say '📁 Auto-detected multi-database setup!', :green
|
|
138
|
+
say ' Database: ' + detected_config[:database_name].to_s, :green
|
|
139
|
+
say ' Migration path: ' + detected_config[:migrations_path].to_s, :green
|
|
140
|
+
else
|
|
141
|
+
say '📁 Using standard migration directory', :green
|
|
142
|
+
|
|
143
|
+
# Runtime check: warn if SolidQueue appears to use separate connection
|
|
144
|
+
if solidqueue_uses_separate_connection?
|
|
145
|
+
say ''
|
|
146
|
+
say '⚠️ Warning: SolidQueue appears to use a separate database connection!', :yellow
|
|
147
|
+
say ' But we could not detect the migrations_paths from database.yml.', :yellow
|
|
148
|
+
say ' If tables end up in the wrong database, re-run with:', :yellow
|
|
149
|
+
say ' rails g solid_queue_autoscaler:migration --database=queue', :yellow
|
|
150
|
+
say ''
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
say ' Placing migrations in: ' + migration_dir + '/', :blue
|
|
155
|
+
say ''
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Check at runtime if SolidQueue uses a separate database connection.
|
|
159
|
+
def solidqueue_uses_separate_connection?
|
|
160
|
+
return false unless defined?(SolidQueue::Record)
|
|
161
|
+
return false unless SolidQueue::Record.respond_to?(:connection)
|
|
162
|
+
|
|
163
|
+
sq_pool = SolidQueue::Record.connection_pool
|
|
164
|
+
ar_pool = ActiveRecord::Base.connection_pool
|
|
165
|
+
|
|
166
|
+
sq_pool.db_config.database != ar_pool.db_config.database
|
|
167
|
+
rescue StandardError
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def print_instructions(migration_dir, db_name)
|
|
172
|
+
say '✅ Migrations generated successfully!', :green
|
|
173
|
+
say ''
|
|
174
|
+
say '📖 To run the migrations:', :blue
|
|
175
|
+
|
|
176
|
+
if db_name
|
|
177
|
+
# Multi-database setup
|
|
178
|
+
say ' rails db:migrate:' + db_name.to_s, :cyan
|
|
179
|
+
say ''
|
|
180
|
+
say ' Or if that does not work:', :blue
|
|
181
|
+
say ' DATABASE=' + db_name.to_s + ' rails db:migrate', :cyan
|
|
182
|
+
elsif migration_dir != 'db/migrate'
|
|
183
|
+
# Custom migration directory without detected database name
|
|
184
|
+
db_from_path = migration_dir.sub('db/', '').sub('_migrate', '')
|
|
185
|
+
say ' rails db:migrate:' + db_from_path, :cyan
|
|
186
|
+
else
|
|
187
|
+
# Standard single-database
|
|
188
|
+
say ' rails db:migrate', :cyan
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
say ''
|
|
192
|
+
say '🔍 To verify setup after migration:', :blue
|
|
193
|
+
say ' SolidQueueAutoscaler.verify_setup!', :cyan
|
|
194
|
+
say ''
|
|
195
|
+
end
|
|
196
|
+
|
|
22
197
|
def migration_version
|
|
23
198
|
return unless defined?(ActiveRecord::VERSION)
|
|
24
199
|
|
|
25
|
-
|
|
200
|
+
'[' + ActiveRecord::VERSION::MAJOR.to_s + '.' +
|
|
201
|
+
ActiveRecord::VERSION::MINOR.to_s + ']'
|
|
26
202
|
end
|
|
27
203
|
end
|
|
28
204
|
end
|
|
@@ -5,24 +5,45 @@ SolidQueueAutoscaler has been installed!
|
|
|
5
5
|
Next steps:
|
|
6
6
|
|
|
7
7
|
1. Set environment variables:
|
|
8
|
-
- HEROKU_API_KEY: Generate with `heroku authorizations:create -d
|
|
8
|
+
- HEROKU_API_KEY: Generate with `heroku authorizations:create -d 'Solid Queue Autoscaler'`
|
|
9
9
|
- HEROKU_APP_NAME: Your Heroku app name
|
|
10
10
|
|
|
11
|
-
2. Run the migration generator
|
|
11
|
+
2. Run the migration generator:
|
|
12
12
|
|
|
13
|
+
SINGLE DATABASE (SolidQueue uses same database as your app):
|
|
14
|
+
---------------------------------------------------------------
|
|
13
15
|
rails generate solid_queue_autoscaler:migration
|
|
14
16
|
rails db:migrate
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
MULTI-DATABASE (SolidQueue in separate 'queue' database):
|
|
19
|
+
---------------------------------------------------------------
|
|
20
|
+
The generator will auto-detect your setup and place migrations
|
|
21
|
+
in the correct directory (e.g., db/queue_migrate/).
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
rails generate solid_queue_autoscaler:migration
|
|
24
|
+
rails db:migrate:queue
|
|
25
|
+
|
|
26
|
+
Or specify explicitly:
|
|
27
|
+
rails generate solid_queue_autoscaler:migration --database=queue
|
|
28
|
+
rails db:migrate:queue
|
|
29
|
+
|
|
30
|
+
3. Verify installation:
|
|
31
|
+
|
|
32
|
+
rails console
|
|
33
|
+
> SolidQueueAutoscaler.verify_setup!
|
|
34
|
+
|
|
35
|
+
This will check that tables are in the correct database.
|
|
36
|
+
|
|
37
|
+
4. Review config/initializers/solid_queue_autoscaler.rb and adjust thresholds
|
|
38
|
+
|
|
39
|
+
5. Add the recurring job to config/recurring.yml:
|
|
19
40
|
|
|
20
41
|
autoscaler:
|
|
21
42
|
class: SolidQueueAutoscaler::AutoscaleJob
|
|
22
43
|
queue: autoscaler
|
|
23
44
|
schedule: every 30 seconds
|
|
24
45
|
|
|
25
|
-
|
|
46
|
+
6. Configure a dedicated queue in config/queue.yml:
|
|
26
47
|
|
|
27
48
|
queues:
|
|
28
49
|
- autoscaler
|
|
@@ -34,7 +55,7 @@ Next steps:
|
|
|
34
55
|
- queues: [default]
|
|
35
56
|
threads: 5
|
|
36
57
|
|
|
37
|
-
|
|
58
|
+
7. Test with dry_run mode before enabling in production
|
|
38
59
|
|
|
39
60
|
For more information, see: https://github.com/reillyse/solid_queue_autoscaler
|
|
40
61
|
|
data/lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_events.rb.erb
CHANGED
|
@@ -1,33 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Migration for SolidQueueAutoscaler events table (for dashboard and event history).
|
|
4
|
+
#
|
|
5
|
+
# For multi-database setups (SolidQueue in separate database):
|
|
6
|
+
# This migration should be placed in db/queue_migrate/ (or your queue DB's migration path)
|
|
7
|
+
# Run with: rails db:migrate:queue
|
|
8
|
+
#
|
|
9
|
+
# For single-database setups:
|
|
10
|
+
# Place in db/migrate/ and run: rails db:migrate
|
|
11
|
+
#
|
|
3
12
|
class CreateSolidQueueAutoscalerEvents < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
-
# Use the same database connection as SolidQueue for multi-database setups
|
|
5
|
-
def self.connection
|
|
6
|
-
if defined?(SolidQueue::Record) && SolidQueue::Record.respond_to?(:connection)
|
|
7
|
-
SolidQueue::Record.connection
|
|
8
|
-
else
|
|
9
|
-
super
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
13
|
def change
|
|
14
14
|
create_table :solid_queue_autoscaler_events do |t|
|
|
15
|
-
t.string :worker_name, null: false
|
|
15
|
+
t.string :worker_name, null: false, default: 'default'
|
|
16
16
|
t.string :action, null: false
|
|
17
|
-
t.integer :from_workers, null: false
|
|
18
|
-
t.integer :to_workers, null: false
|
|
19
|
-
t.
|
|
20
|
-
t.integer :queue_depth
|
|
21
|
-
t.float :latency_seconds
|
|
22
|
-
t.
|
|
17
|
+
t.integer :from_workers, null: false
|
|
18
|
+
t.integer :to_workers, null: false
|
|
19
|
+
t.string :reason
|
|
20
|
+
t.integer :queue_depth
|
|
21
|
+
t.float :latency_seconds
|
|
22
|
+
t.text :metrics_json
|
|
23
23
|
t.boolean :dry_run, default: false
|
|
24
|
-
|
|
25
24
|
t.datetime :created_at, null: false
|
|
26
25
|
end
|
|
27
26
|
|
|
27
|
+
add_index :solid_queue_autoscaler_events, :created_at
|
|
28
28
|
add_index :solid_queue_autoscaler_events, :worker_name
|
|
29
29
|
add_index :solid_queue_autoscaler_events, :action
|
|
30
|
-
add_index :solid_queue_autoscaler_events, :created_at
|
|
31
|
-
add_index :solid_queue_autoscaler_events, %i[worker_name created_at]
|
|
32
30
|
end
|
|
33
31
|
end
|
data/lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_state.rb.erb
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Migration for SolidQueueAutoscaler cooldown state table.
|
|
4
|
+
#
|
|
5
|
+
# For multi-database setups (SolidQueue in separate database):
|
|
6
|
+
# This migration should be placed in db/queue_migrate/ (or your queue DB's migration path)
|
|
7
|
+
# Run with: rails db:migrate:queue
|
|
8
|
+
#
|
|
9
|
+
# For single-database setups:
|
|
10
|
+
# Place in db/migrate/ and run: rails db:migrate
|
|
11
|
+
#
|
|
3
12
|
class CreateSolidQueueAutoscalerState < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
-
# Use the same database connection as SolidQueue for multi-database setups
|
|
5
|
-
def self.connection
|
|
6
|
-
if defined?(SolidQueue::Record) && SolidQueue::Record.respond_to?(:connection)
|
|
7
|
-
SolidQueue::Record.connection
|
|
8
|
-
else
|
|
9
|
-
super
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
13
|
def change
|
|
14
14
|
create_table :solid_queue_autoscaler_state do |t|
|
|
15
15
|
t.string :key, null: false
|
|
@@ -2,39 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidQueueAutoscaler
|
|
4
4
|
class AutoscaleJob < ActiveJob::Base
|
|
5
|
-
# Use
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
# Handle both Symbol and String values safely
|
|
16
|
-
worker_name.to_sym rescue :default
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
SolidQueueAutoscaler.config(config_name).job_queue || :autoscaler
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Use configured priority for the target worker (defaults to nil/no priority)
|
|
23
|
-
queue_with_priority do
|
|
24
|
-
# perform(worker_name = :default)
|
|
25
|
-
worker_name = arguments.first
|
|
26
|
-
|
|
27
|
-
# When scaling all workers, or when worker_name is nil, use the default configuration
|
|
28
|
-
config_name =
|
|
29
|
-
if worker_name.nil? || worker_name == :all || worker_name == "all"
|
|
30
|
-
:default
|
|
31
|
-
else
|
|
32
|
-
# Handle both Symbol and String values safely
|
|
33
|
-
worker_name.to_sym rescue :default
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
SolidQueueAutoscaler.config(config_name).job_priority
|
|
37
|
-
end
|
|
5
|
+
# IMPORTANT: Use a static queue name so SolidQueue recurring jobs work correctly.
|
|
6
|
+
# When using SolidQueue recurring.yml without specifying queue:, SolidQueue
|
|
7
|
+
# checks the job class's queue_name attribute. A dynamic queue_as block
|
|
8
|
+
# returns a Proc that isn't evaluated by recurring jobs, causing jobs to
|
|
9
|
+
# go to 'default' queue instead.
|
|
10
|
+
#
|
|
11
|
+
# To use a custom queue:
|
|
12
|
+
# 1. Set queue: in your recurring.yml (recommended)
|
|
13
|
+
# 2. Or use AutoscaleJob.set(queue: :my_queue).perform_later
|
|
14
|
+
queue_as :autoscaler
|
|
38
15
|
|
|
39
16
|
discard_on ConfigurationError
|
|
40
17
|
|
|
@@ -63,6 +63,9 @@ module SolidQueueAutoscaler
|
|
|
63
63
|
# Dashboard/event recording settings
|
|
64
64
|
attr_accessor :record_events, :record_all_events
|
|
65
65
|
|
|
66
|
+
# Cooldown persistence (survives process restarts)
|
|
67
|
+
attr_accessor :persist_cooldowns
|
|
68
|
+
|
|
66
69
|
# AutoscaleJob settings
|
|
67
70
|
attr_accessor :job_queue, :job_priority
|
|
68
71
|
|
|
@@ -132,6 +135,9 @@ module SolidQueueAutoscaler
|
|
|
132
135
|
@record_events = true # Record scale events to database
|
|
133
136
|
@record_all_events = false # Also record no_change events (verbose)
|
|
134
137
|
|
|
138
|
+
# Cooldown persistence (survives process restarts, requires migration)
|
|
139
|
+
@persist_cooldowns = true
|
|
140
|
+
|
|
135
141
|
# AutoscaleJob settings
|
|
136
142
|
@job_queue = :autoscaler # Queue name for the autoscaler job
|
|
137
143
|
@job_priority = nil # Job priority (lower = higher priority, nil = default)
|
|
@@ -48,6 +48,124 @@ module SolidQueueAutoscaler
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
class << self
|
|
51
|
+
# Diagnostic method to help debug why events aren't being saved.
|
|
52
|
+
# @param config [Configuration, nil] Configuration to check (uses default if nil)
|
|
53
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter, nil] Database connection
|
|
54
|
+
# @return [Hash] Diagnostic information
|
|
55
|
+
def debug_event_saving(config: nil, connection: nil)
|
|
56
|
+
config ||= SolidQueueAutoscaler.config rescue nil
|
|
57
|
+
conn = connection || default_connection rescue nil
|
|
58
|
+
|
|
59
|
+
diagnostics = {
|
|
60
|
+
timestamp: Time.current,
|
|
61
|
+
config_present: !config.nil?,
|
|
62
|
+
connection_present: !conn.nil?,
|
|
63
|
+
record_events_setting: nil,
|
|
64
|
+
connection_available: false,
|
|
65
|
+
record_events_effective: false,
|
|
66
|
+
table_exists: false,
|
|
67
|
+
can_query: false,
|
|
68
|
+
can_insert: false,
|
|
69
|
+
issues: [],
|
|
70
|
+
connection_class: nil,
|
|
71
|
+
error: nil
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Check config settings
|
|
75
|
+
if config
|
|
76
|
+
diagnostics[:record_events_setting] = config.record_events
|
|
77
|
+
diagnostics[:connection_available] = config.connection_available?
|
|
78
|
+
diagnostics[:record_events_effective] = config.record_events?
|
|
79
|
+
|
|
80
|
+
unless config.record_events
|
|
81
|
+
diagnostics[:issues] << 'config.record_events is false'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
unless config.connection_available?
|
|
85
|
+
diagnostics[:issues] << 'config.connection_available? returned false'
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
diagnostics[:issues] << 'No configuration found'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check connection
|
|
92
|
+
if conn
|
|
93
|
+
diagnostics[:connection_class] = conn.class.name
|
|
94
|
+
|
|
95
|
+
# Check table exists
|
|
96
|
+
begin
|
|
97
|
+
diagnostics[:table_exists] = conn.table_exists?(TABLE_NAME)
|
|
98
|
+
unless diagnostics[:table_exists]
|
|
99
|
+
diagnostics[:issues] << "Table '#{TABLE_NAME}' does not exist. Run: rails generate solid_queue_autoscaler:dashboard"
|
|
100
|
+
end
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
diagnostics[:issues] << "Error checking table existence: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Check can query
|
|
106
|
+
if diagnostics[:table_exists]
|
|
107
|
+
begin
|
|
108
|
+
conn.select_value("SELECT COUNT(*) FROM #{TABLE_NAME}")
|
|
109
|
+
diagnostics[:can_query] = true
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
diagnostics[:issues] << "Error querying table: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Check can insert (with rollback)
|
|
116
|
+
if diagnostics[:can_query]
|
|
117
|
+
begin
|
|
118
|
+
# Try a test insert that we'll check but not actually commit
|
|
119
|
+
test_sql = <<~SQL
|
|
120
|
+
INSERT INTO #{TABLE_NAME}
|
|
121
|
+
(worker_name, action, from_workers, to_workers, reason,
|
|
122
|
+
queue_depth, latency_seconds, metrics_json, dry_run, created_at)
|
|
123
|
+
VALUES
|
|
124
|
+
(#{conn.quote('_debug_test')},
|
|
125
|
+
#{conn.quote('skipped')},
|
|
126
|
+
#{conn.quote(0)},
|
|
127
|
+
#{conn.quote(0)},
|
|
128
|
+
#{conn.quote('debug test - should be deleted')},
|
|
129
|
+
#{conn.quote(0)},
|
|
130
|
+
#{conn.quote(0.0)},
|
|
131
|
+
#{conn.quote(nil)},
|
|
132
|
+
#{conn.quote(true)},
|
|
133
|
+
#{conn.quote(Time.current)})
|
|
134
|
+
RETURNING id
|
|
135
|
+
SQL
|
|
136
|
+
|
|
137
|
+
result = conn.execute(test_sql)
|
|
138
|
+
test_id = result.first&.fetch('id', nil)
|
|
139
|
+
|
|
140
|
+
if test_id
|
|
141
|
+
diagnostics[:can_insert] = true
|
|
142
|
+
# Clean up test record
|
|
143
|
+
conn.execute("DELETE FROM #{TABLE_NAME} WHERE id = #{test_id}")
|
|
144
|
+
else
|
|
145
|
+
diagnostics[:issues] << 'INSERT succeeded but no id returned'
|
|
146
|
+
end
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
diagnostics[:issues] << "Error inserting test record: #{e.message}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
else
|
|
152
|
+
diagnostics[:issues] << 'No database connection available'
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Summary
|
|
156
|
+
diagnostics[:would_save_events] = diagnostics[:record_events_effective] &&
|
|
157
|
+
diagnostics[:table_exists] &&
|
|
158
|
+
diagnostics[:can_insert]
|
|
159
|
+
|
|
160
|
+
diagnostics
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
{
|
|
163
|
+
timestamp: Time.current,
|
|
164
|
+
error: "#{e.class}: #{e.message}",
|
|
165
|
+
issues: ["Unexpected error during diagnostics: #{e.message}"]
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
51
169
|
# Creates a new scale event record.
|
|
52
170
|
# @param attrs [Hash] Event attributes
|
|
53
171
|
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
@@ -209,9 +327,79 @@ module SolidQueueAutoscaler
|
|
|
209
327
|
0
|
|
210
328
|
end
|
|
211
329
|
|
|
330
|
+
# Returns diagnostic information to help debug event storage issues.
|
|
331
|
+
# Use this method in Rails console to understand why events might not be stored.
|
|
332
|
+
#
|
|
333
|
+
# @example Check why events aren't being stored
|
|
334
|
+
# SolidQueueAutoscaler::ScaleEvent.diagnostics
|
|
335
|
+
# # => { table_exists: false, error: "Table does not exist..." }
|
|
336
|
+
#
|
|
337
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
338
|
+
# @return [Hash] Diagnostic information
|
|
339
|
+
def diagnostics(connection: nil)
|
|
340
|
+
conn = connection || default_connection
|
|
341
|
+
result = {
|
|
342
|
+
table_name: TABLE_NAME,
|
|
343
|
+
connection_class: conn.class.name,
|
|
344
|
+
table_exists: false,
|
|
345
|
+
event_count: 0,
|
|
346
|
+
recent_events: 0,
|
|
347
|
+
last_event_at: nil,
|
|
348
|
+
error: nil
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
# Check table existence
|
|
352
|
+
begin
|
|
353
|
+
result[:table_exists] = conn.table_exists?(TABLE_NAME)
|
|
354
|
+
rescue StandardError => e
|
|
355
|
+
result[:error] = "Failed to check table existence: #{e.message}"
|
|
356
|
+
return result
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
unless result[:table_exists]
|
|
360
|
+
result[:error] = "Table does not exist. Run: rails generate solid_queue_autoscaler:migration && rails db:migrate"
|
|
361
|
+
return result
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Get event counts
|
|
365
|
+
begin
|
|
366
|
+
result[:event_count] = count(connection: conn)
|
|
367
|
+
result[:recent_events] = count(since: 24.hours.ago, connection: conn)
|
|
368
|
+
|
|
369
|
+
# Get last event time
|
|
370
|
+
sql = "SELECT MAX(created_at) FROM #{TABLE_NAME}"
|
|
371
|
+
last_at = conn.select_value(sql)
|
|
372
|
+
result[:last_event_at] = last_at ? parse_time(last_at) : nil
|
|
373
|
+
rescue StandardError => e
|
|
374
|
+
result[:error] = "Failed to query events: #{e.message}"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Add configuration info
|
|
378
|
+
begin
|
|
379
|
+
config = SolidQueueAutoscaler.config
|
|
380
|
+
result[:config] = {
|
|
381
|
+
record_events: config.record_events,
|
|
382
|
+
record_all_events: config.record_all_events,
|
|
383
|
+
record_events_effective: config.record_events?,
|
|
384
|
+
connection_available: config.connection_available?
|
|
385
|
+
}
|
|
386
|
+
rescue StandardError => e
|
|
387
|
+
result[:config_error] = e.message
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
result
|
|
391
|
+
rescue StandardError => e
|
|
392
|
+
{ error: "Diagnostics failed: #{e.message}" }
|
|
393
|
+
end
|
|
394
|
+
|
|
212
395
|
private
|
|
213
396
|
|
|
214
397
|
def default_connection
|
|
398
|
+
# Use SolidQueue's connection for multi-database setups (same as Configuration.connection)
|
|
399
|
+
if defined?(SolidQueue::Record) && SolidQueue::Record.respond_to?(:connection)
|
|
400
|
+
return SolidQueue::Record.connection
|
|
401
|
+
end
|
|
402
|
+
|
|
215
403
|
ActiveRecord::Base.connection
|
|
216
404
|
end
|
|
217
405
|
|
|
@@ -82,6 +82,7 @@ module SolidQueueAutoscaler
|
|
|
82
82
|
@metrics_collector = Metrics.new(config: @config)
|
|
83
83
|
@decision_engine = DecisionEngine.new(config: @config)
|
|
84
84
|
@adapter = @config.adapter
|
|
85
|
+
@cooldown_tracker = nil # Lazy-loaded when persist_cooldowns is enabled
|
|
85
86
|
end
|
|
86
87
|
|
|
87
88
|
def run
|
|
@@ -124,44 +125,117 @@ module SolidQueueAutoscaler
|
|
|
124
125
|
end
|
|
125
126
|
|
|
126
127
|
def apply_decision(decision, metrics)
|
|
127
|
-
|
|
128
|
+
# Re-verify current workers to catch race conditions where another instance
|
|
129
|
+
# may have scaled while we were making our decision
|
|
130
|
+
verified_current = @adapter.current_workers
|
|
131
|
+
|
|
132
|
+
if verified_current != decision.from
|
|
133
|
+
logger.warn(
|
|
134
|
+
"[Autoscaler] Worker count changed during decision: expected=#{decision.from}, actual=#{verified_current}. " \
|
|
135
|
+
"Re-evaluating..."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# If we're already at or above max, don't scale up
|
|
139
|
+
if decision.scale_up? && verified_current >= @config.max_workers
|
|
140
|
+
return skipped_result(
|
|
141
|
+
"Aborted scale_up: already at max_workers (#{verified_current} >= #{@config.max_workers})",
|
|
142
|
+
decision: decision,
|
|
143
|
+
metrics: metrics
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# If we're already at or below min, don't scale down
|
|
148
|
+
if decision.scale_down? && verified_current <= @config.min_workers
|
|
149
|
+
return skipped_result(
|
|
150
|
+
"Aborted scale_down: already at min_workers (#{verified_current} <= #{@config.min_workers})",
|
|
151
|
+
decision: decision,
|
|
152
|
+
metrics: metrics
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Final safety clamp: never exceed configured limits
|
|
158
|
+
target = decision.to.clamp(@config.min_workers, @config.max_workers)
|
|
159
|
+
|
|
160
|
+
if target != decision.to
|
|
161
|
+
logger.warn(
|
|
162
|
+
"[Autoscaler] Clamping target from #{decision.to} to #{target} " \
|
|
163
|
+
"(limits: #{@config.min_workers}-#{@config.max_workers})"
|
|
164
|
+
)
|
|
165
|
+
# Ensure decision reflects the clamped target for logging and events
|
|
166
|
+
decision.to = target
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
@adapter.scale(target)
|
|
128
170
|
record_scale_time(decision)
|
|
129
171
|
record_scale_event(decision, metrics)
|
|
130
|
-
|
|
172
|
+
|
|
131
173
|
log_scale_action(decision)
|
|
132
174
|
|
|
133
175
|
success_result(decision, metrics)
|
|
134
176
|
end
|
|
135
177
|
|
|
136
178
|
def cooldown_active?(decision)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
Time.current - last_scale_down < @config.effective_scale_down_cooldown
|
|
179
|
+
if @config.persist_cooldowns && cooldown_tracker.table_exists?
|
|
180
|
+
# Use database-persisted cooldowns (survives process restarts)
|
|
181
|
+
if decision.scale_up?
|
|
182
|
+
cooldown_tracker.cooldown_active_for_scale_up?
|
|
183
|
+
elsif decision.scale_down?
|
|
184
|
+
cooldown_tracker.cooldown_active_for_scale_down?
|
|
185
|
+
else
|
|
186
|
+
false
|
|
187
|
+
end
|
|
148
188
|
else
|
|
149
|
-
|
|
189
|
+
# Fall back to in-memory cooldowns
|
|
190
|
+
config_name = @config.name
|
|
191
|
+
if decision.scale_up?
|
|
192
|
+
last_scale_up = self.class.last_scale_up_at(config_name)
|
|
193
|
+
return false unless last_scale_up
|
|
194
|
+
|
|
195
|
+
Time.current - last_scale_up < @config.effective_scale_up_cooldown
|
|
196
|
+
elsif decision.scale_down?
|
|
197
|
+
last_scale_down = self.class.last_scale_down_at(config_name)
|
|
198
|
+
return false unless last_scale_down
|
|
199
|
+
|
|
200
|
+
Time.current - last_scale_down < @config.effective_scale_down_cooldown
|
|
201
|
+
else
|
|
202
|
+
false
|
|
203
|
+
end
|
|
150
204
|
end
|
|
151
205
|
end
|
|
152
206
|
|
|
153
207
|
def cooldown_remaining(decision)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
208
|
+
if @config.persist_cooldowns && cooldown_tracker.table_exists?
|
|
209
|
+
# Use database-persisted cooldowns
|
|
210
|
+
if decision.scale_up?
|
|
211
|
+
cooldown_tracker.scale_up_cooldown_remaining
|
|
212
|
+
else
|
|
213
|
+
cooldown_tracker.scale_down_cooldown_remaining
|
|
214
|
+
end
|
|
158
215
|
else
|
|
159
|
-
|
|
160
|
-
@config.
|
|
216
|
+
# Fall back to in-memory cooldowns
|
|
217
|
+
config_name = @config.name
|
|
218
|
+
if decision.scale_up?
|
|
219
|
+
elapsed = Time.current - self.class.last_scale_up_at(config_name)
|
|
220
|
+
@config.effective_scale_up_cooldown - elapsed
|
|
221
|
+
else
|
|
222
|
+
elapsed = Time.current - self.class.last_scale_down_at(config_name)
|
|
223
|
+
@config.effective_scale_down_cooldown - elapsed
|
|
224
|
+
end
|
|
161
225
|
end
|
|
162
226
|
end
|
|
163
227
|
|
|
164
228
|
def record_scale_time(decision)
|
|
229
|
+
if @config.persist_cooldowns && cooldown_tracker.table_exists?
|
|
230
|
+
# Use database-persisted cooldowns
|
|
231
|
+
if decision.scale_up?
|
|
232
|
+
cooldown_tracker.record_scale_up!
|
|
233
|
+
elsif decision.scale_down?
|
|
234
|
+
cooldown_tracker.record_scale_down!
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Always update in-memory cooldowns as well (for immediate effect within same process)
|
|
165
239
|
config_name = @config.name
|
|
166
240
|
if decision.scale_up?
|
|
167
241
|
self.class.set_last_scale_up_at(config_name, Time.current)
|
|
@@ -170,6 +244,10 @@ module SolidQueueAutoscaler
|
|
|
170
244
|
end
|
|
171
245
|
end
|
|
172
246
|
|
|
247
|
+
def cooldown_tracker
|
|
248
|
+
@cooldown_tracker ||= CooldownTracker.new(config: @config, key: @config.name.to_s)
|
|
249
|
+
end
|
|
250
|
+
|
|
173
251
|
def log_decision(decision, metrics)
|
|
174
252
|
worker_label = @config.name == :default ? '' : "[#{@config.name}] "
|
|
175
253
|
logger.info(
|
|
@@ -99,6 +99,260 @@ module SolidQueueAutoscaler
|
|
|
99
99
|
configurations[:default] = config_obj
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
|
+
|
|
103
|
+
# Verify the installation is complete and working.
|
|
104
|
+
# Prints a human-friendly report (when verbose: true) and returns a VerificationResult.
|
|
105
|
+
#
|
|
106
|
+
# Usage (Rails/Heroku console):
|
|
107
|
+
# SolidQueueAutoscaler.verify_setup!
|
|
108
|
+
# # or alias:
|
|
109
|
+
# SolidQueueAutoscaler.verify_install!
|
|
110
|
+
#
|
|
111
|
+
# You can also inspect the returned struct:
|
|
112
|
+
# result = SolidQueueAutoscaler.verify_setup!(verbose: false)
|
|
113
|
+
# result.ok? # => true/false
|
|
114
|
+
# result.to_h # => hash of details
|
|
115
|
+
def verify_setup!(name = :default, verbose: true)
|
|
116
|
+
result = VerificationResult.new
|
|
117
|
+
cfg = config(name)
|
|
118
|
+
connection = cfg.connection
|
|
119
|
+
|
|
120
|
+
output = []
|
|
121
|
+
output << '=' * 60
|
|
122
|
+
output << 'SolidQueueAutoscaler Setup Verification'
|
|
123
|
+
output << '=' * 60
|
|
124
|
+
output << ''
|
|
125
|
+
output << "Version: #{VERSION}"
|
|
126
|
+
output << "Configuration: #{name}"
|
|
127
|
+
|
|
128
|
+
# Check connection type (handles SolidQueue in its own DB)
|
|
129
|
+
if defined?(SolidQueue::Record) && SolidQueue::Record.respond_to?(:connection)
|
|
130
|
+
output << '✓ Using SolidQueue::Record connection (multi-database setup)'
|
|
131
|
+
result.connection_type = :solid_queue_record
|
|
132
|
+
else
|
|
133
|
+
output << '✓ Using ActiveRecord::Base connection'
|
|
134
|
+
result.connection_type = :active_record_base
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# 1. Cooldown state table
|
|
138
|
+
output << ''
|
|
139
|
+
output << '-' * 60
|
|
140
|
+
output << '1. COOLDOWN STATE TABLE (solid_queue_autoscaler_state)'
|
|
141
|
+
output << '-' * 60
|
|
142
|
+
|
|
143
|
+
if connection.table_exists?(:solid_queue_autoscaler_state)
|
|
144
|
+
result.state_table_exists = true
|
|
145
|
+
output << '✓ Table exists'
|
|
146
|
+
|
|
147
|
+
columns = connection.columns(:solid_queue_autoscaler_state).map(&:name)
|
|
148
|
+
expected = %w[id key last_scale_up_at last_scale_down_at created_at updated_at]
|
|
149
|
+
missing = expected - columns
|
|
150
|
+
|
|
151
|
+
if missing.empty?
|
|
152
|
+
result.state_table_columns_ok = true
|
|
153
|
+
output << ' ✓ All expected columns present'
|
|
154
|
+
else
|
|
155
|
+
result.state_table_columns_ok = false
|
|
156
|
+
result.add_warning("State table missing columns: #{missing.join(', ')}")
|
|
157
|
+
output << " ⚠ Missing columns: #{missing.join(', ')}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
state_count = connection.select_value('SELECT COUNT(*) FROM solid_queue_autoscaler_state').to_i
|
|
161
|
+
output << " Current records: #{state_count}"
|
|
162
|
+
else
|
|
163
|
+
result.state_table_exists = false
|
|
164
|
+
result.add_error('Cooldown state table does not exist')
|
|
165
|
+
output << '✗ Table DOES NOT EXIST'
|
|
166
|
+
output << ' Run: rails generate solid_queue_autoscaler:migration && rails db:migrate'
|
|
167
|
+
output << ' ⚠ Cooldowns are NOT shared across workers (using in-memory fallback)'
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# 2. Events table
|
|
171
|
+
output << ''
|
|
172
|
+
output << '-' * 60
|
|
173
|
+
output << '2. EVENTS TABLE (solid_queue_autoscaler_events)'
|
|
174
|
+
output << '-' * 60
|
|
175
|
+
|
|
176
|
+
if connection.table_exists?(:solid_queue_autoscaler_events)
|
|
177
|
+
result.events_table_exists = true
|
|
178
|
+
output << '✓ Table exists'
|
|
179
|
+
|
|
180
|
+
columns = connection.columns(:solid_queue_autoscaler_events).map(&:name)
|
|
181
|
+
expected = %w[id worker_name action from_workers to_workers reason queue_depth latency_seconds metrics_json dry_run created_at]
|
|
182
|
+
missing = expected - columns
|
|
183
|
+
|
|
184
|
+
if missing.empty?
|
|
185
|
+
result.events_table_columns_ok = true
|
|
186
|
+
output << ' ✓ All expected columns present'
|
|
187
|
+
else
|
|
188
|
+
result.events_table_columns_ok = false
|
|
189
|
+
result.add_warning("Events table missing columns: #{missing.join(', ')}")
|
|
190
|
+
output << " ⚠ Missing columns: #{missing.join(', ')}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
event_count = connection.select_value('SELECT COUNT(*) FROM solid_queue_autoscaler_events').to_i
|
|
194
|
+
output << " Total events: #{event_count}"
|
|
195
|
+
else
|
|
196
|
+
result.events_table_exists = false
|
|
197
|
+
result.add_error('Events table does not exist')
|
|
198
|
+
output << '✗ Table DOES NOT EXIST'
|
|
199
|
+
output << ' Run: rails generate solid_queue_autoscaler:migration && rails db:migrate'
|
|
200
|
+
output << ' ⚠ Scale events are NOT being recorded (dashboard will be empty)'
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# 3. Configuration
|
|
204
|
+
output << ''
|
|
205
|
+
output << '-' * 60
|
|
206
|
+
output << '3. CONFIGURATION'
|
|
207
|
+
output << '-' * 60
|
|
208
|
+
|
|
209
|
+
begin
|
|
210
|
+
result.config_valid = true
|
|
211
|
+
output << '✓ Configuration loaded'
|
|
212
|
+
output << " enabled: #{cfg.enabled?}"
|
|
213
|
+
output << " dry_run: #{cfg.dry_run?}"
|
|
214
|
+
output << " persist_cooldowns: #{cfg.respond_to?(:persist_cooldowns) ? cfg.persist_cooldowns : '(not supported in this version)'}"
|
|
215
|
+
output << " record_events: #{cfg.respond_to?(:record_events) ? cfg.record_events : '(not supported in this version)'}"
|
|
216
|
+
output << " min_workers: #{cfg.min_workers}"
|
|
217
|
+
output << " max_workers: #{cfg.max_workers}"
|
|
218
|
+
output << " job_queue: #{cfg.job_queue}"
|
|
219
|
+
output << " adapter: #{cfg.adapter.class.name}"
|
|
220
|
+
rescue StandardError => e
|
|
221
|
+
result.config_valid = false
|
|
222
|
+
result.add_error("Configuration error: #{e.message}")
|
|
223
|
+
output << "✗ Configuration error: #{e.message}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# 4. Adapter connectivity
|
|
227
|
+
output << ''
|
|
228
|
+
output << '-' * 60
|
|
229
|
+
output << '4. ADAPTER CONNECTIVITY'
|
|
230
|
+
output << '-' * 60
|
|
231
|
+
|
|
232
|
+
begin
|
|
233
|
+
workers = cfg.adapter.current_workers
|
|
234
|
+
result.adapter_connected = true
|
|
235
|
+
output << "✓ Adapter connected (current workers: #{workers})"
|
|
236
|
+
rescue StandardError => e
|
|
237
|
+
result.adapter_connected = false
|
|
238
|
+
result.add_error("Adapter connection failed: #{e.message}")
|
|
239
|
+
output << "✗ Adapter connection failed: #{e.message}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# 5. Solid Queue tables
|
|
243
|
+
output << ''
|
|
244
|
+
output << '-' * 60
|
|
245
|
+
output << '5. SOLID QUEUE TABLES'
|
|
246
|
+
output << '-' * 60
|
|
247
|
+
|
|
248
|
+
sq_tables = %w[solid_queue_jobs solid_queue_ready_executions solid_queue_claimed_executions solid_queue_processes]
|
|
249
|
+
result.solid_queue_tables = {}
|
|
250
|
+
|
|
251
|
+
sq_tables.each do |table|
|
|
252
|
+
if connection.table_exists?(table)
|
|
253
|
+
count = connection.select_value("SELECT COUNT(*) FROM #{table}").to_i
|
|
254
|
+
result.solid_queue_tables[table] = count
|
|
255
|
+
output << "✓ #{table}: #{count} records"
|
|
256
|
+
else
|
|
257
|
+
result.solid_queue_tables[table] = nil
|
|
258
|
+
output << "✗ #{table}: MISSING"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Summary
|
|
263
|
+
output << ''
|
|
264
|
+
output << '=' * 60
|
|
265
|
+
output << 'SUMMARY'
|
|
266
|
+
output << '=' * 60
|
|
267
|
+
|
|
268
|
+
if result.ok?
|
|
269
|
+
output << '✓ All checks passed! Autoscaler is correctly configured.'
|
|
270
|
+
if result.cooldowns_shared?
|
|
271
|
+
output << ' Cooldowns: SHARED across workers (database-persisted)'
|
|
272
|
+
else
|
|
273
|
+
output << ' Cooldowns: In-memory only (not shared across workers)'
|
|
274
|
+
end
|
|
275
|
+
if result.events_table_exists
|
|
276
|
+
output << ' Events: RECORDING to database'
|
|
277
|
+
else
|
|
278
|
+
output << ' Events: NOT recording (events table missing)'
|
|
279
|
+
end
|
|
280
|
+
else
|
|
281
|
+
output << '⚠ Some issues found:'
|
|
282
|
+
result.errors.each { |err| output << " ✗ #{err}" }
|
|
283
|
+
result.warnings.each { |warn| output << " ⚠ #{warn}" }
|
|
284
|
+
output << ''
|
|
285
|
+
output << 'To fix missing tables, run:'
|
|
286
|
+
output << ' rails generate solid_queue_autoscaler:migration'
|
|
287
|
+
output << ' rails db:migrate'
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
puts output.join("\n") if verbose
|
|
291
|
+
|
|
292
|
+
nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Convenience alias so users can call verify_install! as requested
|
|
296
|
+
def verify_install!(name = :default, verbose: true)
|
|
297
|
+
verify_setup!(name, verbose: verbose)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Structured result from verify_setup!/verify_install!
|
|
302
|
+
class VerificationResult
|
|
303
|
+
attr_accessor :connection_type,
|
|
304
|
+
:state_table_exists, :state_table_columns_ok,
|
|
305
|
+
:events_table_exists, :events_table_columns_ok,
|
|
306
|
+
:config_valid, :adapter_connected,
|
|
307
|
+
:solid_queue_tables
|
|
308
|
+
|
|
309
|
+
def initialize
|
|
310
|
+
@errors = []
|
|
311
|
+
@warnings = []
|
|
312
|
+
@solid_queue_tables = {}
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def errors
|
|
316
|
+
@errors
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def warnings
|
|
320
|
+
@warnings
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def add_error(message)
|
|
324
|
+
@errors << message
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def add_warning(message)
|
|
328
|
+
@warnings << message
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def ok?
|
|
332
|
+
@errors.empty?
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def tables_exist?
|
|
336
|
+
state_table_exists && events_table_exists
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def cooldowns_shared?
|
|
340
|
+
state_table_exists && state_table_columns_ok
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def to_h
|
|
344
|
+
{
|
|
345
|
+
ok: ok?,
|
|
346
|
+
connection_type: connection_type,
|
|
347
|
+
state_table: { exists: state_table_exists, columns_ok: state_table_columns_ok },
|
|
348
|
+
events_table: { exists: events_table_exists, columns_ok: events_table_columns_ok },
|
|
349
|
+
config_valid: config_valid,
|
|
350
|
+
adapter_connected: adapter_connected,
|
|
351
|
+
solid_queue_tables: solid_queue_tables,
|
|
352
|
+
errors: errors,
|
|
353
|
+
warnings: warnings
|
|
354
|
+
}
|
|
355
|
+
end
|
|
102
356
|
end
|
|
103
357
|
end
|
|
104
358
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_queue_autoscaler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- reillyse
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-01-
|
|
11
|
+
date: 2026-01-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|