solid_queue_autoscaler 1.0.7 → 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.
@@ -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,24 +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
13
  def change
5
14
  create_table :solid_queue_autoscaler_events do |t|
6
- t.string :worker_name, null: false
15
+ t.string :worker_name, null: false, default: 'default'
7
16
  t.string :action, null: false
8
- t.integer :from_workers, null: false, default: 0
9
- t.integer :to_workers, null: false, default: 0
10
- t.text :reason
11
- t.integer :queue_depth, default: 0
12
- t.float :latency_seconds, default: 0.0
13
- 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
14
23
  t.boolean :dry_run, default: false
15
-
16
24
  t.datetime :created_at, null: false
17
25
  end
18
26
 
27
+ add_index :solid_queue_autoscaler_events, :created_at
19
28
  add_index :solid_queue_autoscaler_events, :worker_name
20
29
  add_index :solid_queue_autoscaler_events, :action
21
- add_index :solid_queue_autoscaler_events, :created_at
22
- add_index :solid_queue_autoscaler_events, %i[worker_name created_at]
23
30
  end
24
31
  end
@@ -1,5 +1,14 @@
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
13
  def change
5
14
  create_table :solid_queue_autoscaler_state do |t|
@@ -55,4 +55,10 @@ SolidQueueAutoscaler.configure do |config|
55
55
  config.record_events = true
56
56
  # Also record no_change events (verbose, generates many records)
57
57
  # config.record_all_events = false
58
+
59
+ # AutoscaleJob Settings
60
+ # Queue for the autoscaler job (use a fast/high-priority Solid Queue queue)
61
+ config.job_queue = :autoscaler
62
+ # Priority for the autoscaler job (lower = higher priority, nil = default)
63
+ # config.job_priority = 0 # Uncomment to set highest priority
58
64
  end
@@ -2,7 +2,17 @@
2
2
 
3
3
  module SolidQueueAutoscaler
4
4
  class AutoscaleJob < ActiveJob::Base
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
5
14
  queue_as :autoscaler
15
+
6
16
  discard_on ConfigurationError
7
17
 
8
18
  # Scale a specific worker type, or all workers if :all is passed
@@ -63,6 +63,12 @@ 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
+
69
+ # AutoscaleJob settings
70
+ attr_accessor :job_queue, :job_priority
71
+
66
72
  def initialize
67
73
  # Configuration name (auto-set when using named configurations)
68
74
  @name = :default
@@ -128,6 +134,13 @@ module SolidQueueAutoscaler
128
134
  # Dashboard/event recording settings
129
135
  @record_events = true # Record scale events to database
130
136
  @record_all_events = false # Also record no_change events (verbose)
137
+
138
+ # Cooldown persistence (survives process restarts, requires migration)
139
+ @persist_cooldowns = true
140
+
141
+ # AutoscaleJob settings
142
+ @job_queue = :autoscaler # Queue name for the autoscaler job
143
+ @job_priority = nil # Job priority (lower = higher priority, nil = default)
131
144
  end
132
145
 
133
146
  # Returns the lock key, auto-generating based on name if not explicitly set
@@ -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