solid_queue_autoscaler 1.0.8 → 1.0.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2caadbfc7fd3df7b06be9dbea2e3e667b5e8b49e8c302720fb07d56cfb1a7d1c
4
- data.tar.gz: 0b4c14b56f29e1ed6537ecac97d279661d08d943320df901bf7c73f3a6bd8694
3
+ metadata.gz: 81d3e6d0be9fecbd42eb8e0539acec901babd77d99688e685a051a4c84e8c90f
4
+ data.tar.gz: 987c1f62cec4ce7435a1c5c07d37a01e15705919b81d5e7f8d28cb04688f91f5
5
5
  SHA512:
6
- metadata.gz: f2b316153846191155d72961c4be0e95b6d6c2dd71aedc32a268dff92bd570736e703cc2a8e1e7be564b9899d407c80959350014a3ea167f0f3472d0224d2109
7
- data.tar.gz: 4048d221c232b9b079d08f24e571dcc9b56c7c4b3c69d7ce14262e5986568c87c93d07152664667021e981018da79ac581335db82420070c5fcc243bfe626efd
6
+ metadata.gz: d07364fdabd8df5280f9f9216513fa1f7d0dc480f46a4ee7a7b854c586bf64131fd9f28495cf7cae698df90812c7713eb30c686265cebfeac5008017dab00e7e
7
+ data.tar.gz: cf31a9d4f505b68e80d12efbc36cbfb97452ac9207c66c6075093b77aeb74660b2fd20b722fa41dfd5e1d07be2dd1cf15594815ce1c775db02f6c7314e0e4f11
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.9] - 2025-01-17
11
+
12
+ ### Added
13
+ - **`verify_setup!` method** - New diagnostic method to verify installation is correct
14
+ - Checks database connection (multi-database aware)
15
+ - Verifies both autoscaler tables exist with correct columns
16
+ - Tests adapter connectivity
17
+ - Returns a `VerificationResult` struct with `ok?`, `tables_exist?`, `cooldowns_shared?` methods
18
+ - Run `SolidQueueAutoscaler.verify_setup!` in Rails console to diagnose issues
19
+ - **`verify_install!` alias** - Alias for `verify_setup!`
20
+ - **`persist_cooldowns` configuration option** - Control whether cooldowns are stored in database (default: true) or in-memory
21
+
22
+ ### Fixed
23
+ - **Fixed multi-database migration bug** - Migration generator now correctly handles `migrations_paths` as a string (not just array)
24
+ - Previously, migrations would be placed in a `d/` directory instead of `db/queue_migrate/` due to calling `.first` on a string
25
+ - Migrations now auto-detect the correct directory from your `database.yml` configuration
26
+ - **Removed unreliable `self.connection` override** from migration templates - This didn't work because `SolidQueue::Record` isn't loaded at migration time
27
+ - **Improved migration generator output** - Clearer instructions for single-database vs multi-database setups
28
+
29
+ ### Changed
30
+ - `verify_setup!` returns `nil` instead of the result object to keep console output clean
31
+ - Migration templates no longer try to override the database connection (rely on Rails native multi-database support instead)
32
+
10
33
  ## [1.0.8] - 2025-01-17
11
34
 
12
35
  ### 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
- desc 'Creates the migration for SolidQueueAutoscaler state table'
13
+ class_option :database, type: :string, default: nil,
14
+ desc: 'Specify database for multi-database setups (e.g., --database=queue)'
14
15
 
15
- def create_migration_file
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
- 'db/migrate/create_solid_queue_autoscaler_state.rb'
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
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
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 "Solid Queue Autoscaler"`
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 for persistent cooldown tracking:
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
- 3. Review config/initializers/solid_queue_autoscaler.rb and adjust thresholds
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
- 4. Add the recurring job to config/recurring.yml:
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
- 5. Configure a dedicated queue in config/queue.yml:
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
- 6. Test with dry_run mode before enabling in production
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
 
@@ -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, default: 0
18
- t.integer :to_workers, null: false, default: 0
19
- t.text :reason
20
- t.integer :queue_depth, default: 0
21
- t.float :latency_seconds, default: 0.0
22
- t.jsonb :metrics_json
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
@@ -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 configured queue for the target worker (defaults to :autoscaler)
6
- queue_as do
7
- # perform(worker_name = :default)
8
- worker_name = arguments.first
9
-
10
- # When scaling all workers, or when worker_name is nil, use the default configuration
11
- config_name =
12
- if worker_name.nil? || worker_name == :all || worker_name == "all"
13
- :default
14
- else
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,6 +327,71 @@ 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
@@ -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
- @adapter.scale(decision.to)
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
- config_name = @config.name
138
- if decision.scale_up?
139
- last_scale_up = self.class.last_scale_up_at(config_name)
140
- return false unless last_scale_up
141
-
142
- Time.current - last_scale_up < @config.effective_scale_up_cooldown
143
- elsif decision.scale_down?
144
- last_scale_down = self.class.last_scale_down_at(config_name)
145
- return false unless last_scale_down
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
- false
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
- config_name = @config.name
155
- if decision.scale_up?
156
- elapsed = Time.current - self.class.last_scale_up_at(config_name)
157
- @config.effective_scale_up_cooldown - elapsed
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
- elapsed = Time.current - self.class.last_scale_down_at(config_name)
160
- @config.effective_scale_down_cooldown - elapsed
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(
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidQueueAutoscaler
4
- VERSION = '1.0.8'
4
+ VERSION = '1.0.9'
5
5
  end
@@ -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.8
4
+ version: 1.0.9
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-16 00:00:00.000000000 Z
11
+ date: 2026-01-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord