solid_queue_autoscaler 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +189 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +553 -0
  5. data/lib/generators/solid_queue_autoscaler/dashboard_generator.rb +54 -0
  6. data/lib/generators/solid_queue_autoscaler/install_generator.rb +21 -0
  7. data/lib/generators/solid_queue_autoscaler/migration_generator.rb +29 -0
  8. data/lib/generators/solid_queue_autoscaler/templates/README +41 -0
  9. data/lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_events.rb.erb +24 -0
  10. data/lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_state.rb.erb +15 -0
  11. data/lib/generators/solid_queue_autoscaler/templates/initializer.rb +58 -0
  12. data/lib/solid_queue_autoscaler/adapters/base.rb +102 -0
  13. data/lib/solid_queue_autoscaler/adapters/heroku.rb +93 -0
  14. data/lib/solid_queue_autoscaler/adapters/kubernetes.rb +158 -0
  15. data/lib/solid_queue_autoscaler/adapters.rb +57 -0
  16. data/lib/solid_queue_autoscaler/advisory_lock.rb +71 -0
  17. data/lib/solid_queue_autoscaler/autoscale_job.rb +71 -0
  18. data/lib/solid_queue_autoscaler/configuration.rb +269 -0
  19. data/lib/solid_queue_autoscaler/cooldown_tracker.rb +153 -0
  20. data/lib/solid_queue_autoscaler/dashboard/engine.rb +136 -0
  21. data/lib/solid_queue_autoscaler/dashboard/views/layouts/solid_queue_heroku_autoscaler/dashboard/application.html.erb +206 -0
  22. data/lib/solid_queue_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/dashboard/index.html.erb +138 -0
  23. data/lib/solid_queue_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/events/index.html.erb +102 -0
  24. data/lib/solid_queue_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/workers/index.html.erb +106 -0
  25. data/lib/solid_queue_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/workers/show.html.erb +209 -0
  26. data/lib/solid_queue_autoscaler/dashboard.rb +99 -0
  27. data/lib/solid_queue_autoscaler/decision_engine.rb +228 -0
  28. data/lib/solid_queue_autoscaler/errors.rb +44 -0
  29. data/lib/solid_queue_autoscaler/metrics.rb +172 -0
  30. data/lib/solid_queue_autoscaler/railtie.rb +179 -0
  31. data/lib/solid_queue_autoscaler/scale_event.rb +292 -0
  32. data/lib/solid_queue_autoscaler/scaler.rb +294 -0
  33. data/lib/solid_queue_autoscaler/version.rb +5 -0
  34. data/lib/solid_queue_autoscaler.rb +108 -0
  35. metadata +179 -0
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module SolidQueueAutoscaler
6
+ class Configuration
7
+ # Configuration name (for multi-worker support)
8
+ attr_accessor :name
9
+
10
+ # Heroku settings
11
+ attr_accessor :heroku_api_key
12
+
13
+ # Worker limits
14
+ attr_accessor :min_workers
15
+
16
+ # Scale-up thresholds
17
+ attr_accessor :scale_up_queue_depth
18
+
19
+ # Scale-down thresholds
20
+ attr_accessor :scale_down_queue_depth
21
+
22
+ # Scaling strategy
23
+ attr_accessor :scaling_strategy
24
+
25
+ # Safety settings
26
+ attr_accessor :cooldown_seconds
27
+
28
+ # Advisory lock settings
29
+ attr_accessor :lock_timeout_seconds
30
+
31
+ # Behavior settings
32
+ attr_accessor :dry_run
33
+
34
+ # Queue filtering
35
+ attr_accessor :queues
36
+
37
+ # Database connection
38
+ attr_accessor :database_connection
39
+
40
+ # Solid Queue table prefix (default: 'solid_queue_')
41
+ attr_accessor :table_prefix
42
+
43
+ # Infrastructure adapter (defaults to Heroku)
44
+ attr_accessor :adapter_class
45
+
46
+ # Kubernetes settings (for Kubernetes adapter)
47
+ attr_accessor :kubernetes_deployment, :kubernetes_namespace, :kubernetes_context, :kubernetes_kubeconfig
48
+
49
+ # Additional Heroku settings
50
+ attr_accessor :heroku_app_name, :process_type, :max_workers
51
+
52
+ # Scale-up settings
53
+ attr_accessor :scale_up_latency_seconds, :scale_up_increment
54
+
55
+ # Scale-down settings
56
+ attr_accessor :scale_down_latency_seconds, :scale_down_idle_minutes, :scale_down_decrement
57
+ attr_accessor :scale_up_jobs_per_worker, :scale_up_latency_per_worker, :scale_up_cooldown_seconds, :scale_down_jobs_per_worker, :scale_down_cooldown_seconds
58
+
59
+ # Other settings
60
+ attr_accessor :enabled, :logger
61
+ attr_writer :lock_key
62
+
63
+ # Dashboard/event recording settings
64
+ attr_accessor :record_events, :record_all_events
65
+
66
+ def initialize
67
+ # Configuration name (auto-set when using named configurations)
68
+ @name = :default
69
+
70
+ # Heroku settings - required
71
+ @heroku_api_key = ENV.fetch('HEROKU_API_KEY', nil)
72
+ @heroku_app_name = ENV.fetch('HEROKU_APP_NAME', nil)
73
+ @process_type = 'worker'
74
+
75
+ # Worker limits
76
+ @min_workers = 1
77
+ @max_workers = 10
78
+
79
+ # Scale-up thresholds
80
+ @scale_up_queue_depth = 100
81
+ @scale_up_latency_seconds = 300
82
+ @scale_up_increment = 1
83
+
84
+ # Scale-down thresholds
85
+ @scale_down_queue_depth = 10
86
+ @scale_down_latency_seconds = 30
87
+ @scale_down_idle_minutes = 5
88
+ @scale_down_decrement = 1
89
+
90
+ # Scaling strategy (:fixed or :proportional)
91
+ @scaling_strategy = :fixed
92
+ @scale_up_jobs_per_worker = 50
93
+ @scale_up_latency_per_worker = 60
94
+ @scale_down_jobs_per_worker = 50
95
+
96
+ # Safety settings
97
+ @cooldown_seconds = 120
98
+ @scale_up_cooldown_seconds = nil
99
+ @scale_down_cooldown_seconds = nil
100
+
101
+ # Advisory lock settings
102
+ @lock_timeout_seconds = 30
103
+ @lock_key = nil # Auto-generated based on name if not set
104
+
105
+ # Behavior
106
+ @dry_run = false
107
+ @enabled = true
108
+ @logger = default_logger
109
+
110
+ # Queue filtering (nil = all queues)
111
+ @queues = nil
112
+
113
+ # Database connection (defaults to ActiveRecord::Base.connection)
114
+ @database_connection = nil
115
+
116
+ # Solid Queue table prefix (default: 'solid_queue_')
117
+ @table_prefix = 'solid_queue_'
118
+
119
+ # Infrastructure adapter (defaults to Heroku)
120
+ @adapter_class = nil
121
+
122
+ # Kubernetes settings (for Kubernetes adapter)
123
+ @kubernetes_deployment = ENV.fetch('K8S_DEPLOYMENT', nil)
124
+ @kubernetes_namespace = ENV['K8S_NAMESPACE'] || 'default'
125
+ @kubernetes_context = ENV.fetch('K8S_CONTEXT', nil)
126
+ @kubernetes_kubeconfig = ENV.fetch('KUBECONFIG', nil)
127
+
128
+ # Dashboard/event recording settings
129
+ @record_events = true # Record scale events to database
130
+ @record_all_events = false # Also record no_change events (verbose)
131
+ end
132
+
133
+ # Returns the lock key, auto-generating based on name if not explicitly set
134
+ # Each worker type gets a unique lock to allow parallel scaling
135
+ def lock_key
136
+ @lock_key || "solid_queue_autoscaler_#{name}"
137
+ end
138
+
139
+ VALID_SCALING_STRATEGIES = %i[fixed proportional].freeze
140
+
141
+ def validate!
142
+ errors = []
143
+
144
+ # Validate adapter-specific configuration
145
+ errors.concat(adapter.configuration_errors)
146
+
147
+ errors << 'min_workers must be >= 0' if min_workers.negative?
148
+ errors << 'max_workers must be > 0' if max_workers <= 0
149
+ errors << 'min_workers cannot exceed max_workers' if min_workers > max_workers
150
+
151
+ errors << 'scale_up_queue_depth must be > 0' if scale_up_queue_depth <= 0
152
+ errors << 'scale_up_latency_seconds must be > 0' if scale_up_latency_seconds <= 0
153
+ errors << 'scale_up_increment must be > 0' if scale_up_increment <= 0
154
+
155
+ errors << 'scale_down_queue_depth must be >= 0' if scale_down_queue_depth.negative?
156
+ errors << 'scale_down_decrement must be > 0' if scale_down_decrement <= 0
157
+
158
+ errors << 'cooldown_seconds must be >= 0' if cooldown_seconds.negative?
159
+ errors << 'lock_timeout_seconds must be > 0' if lock_timeout_seconds <= 0
160
+
161
+ if table_prefix.nil? || table_prefix.to_s.strip.empty?
162
+ errors << 'table_prefix cannot be nil or empty'
163
+ elsif !table_prefix.to_s.end_with?('_')
164
+ errors << 'table_prefix must end with an underscore'
165
+ end
166
+
167
+ unless VALID_SCALING_STRATEGIES.include?(scaling_strategy)
168
+ errors << "scaling_strategy must be one of: #{VALID_SCALING_STRATEGIES.join(', ')}"
169
+ end
170
+
171
+ raise ConfigurationError, errors.join(', ') if errors.any?
172
+
173
+ true
174
+ end
175
+
176
+ def effective_scale_up_cooldown
177
+ scale_up_cooldown_seconds || cooldown_seconds
178
+ end
179
+
180
+ def effective_scale_down_cooldown
181
+ scale_down_cooldown_seconds || cooldown_seconds
182
+ end
183
+
184
+ def connection
185
+ database_connection || ActiveRecord::Base.connection
186
+ end
187
+
188
+ def dry_run?
189
+ dry_run
190
+ end
191
+
192
+ def enabled?
193
+ enabled
194
+ end
195
+
196
+ def record_events?
197
+ record_events && connection_available?
198
+ end
199
+
200
+ def record_all_events?
201
+ record_all_events && record_events?
202
+ end
203
+
204
+ def connection_available?
205
+ return true if database_connection
206
+ return false unless defined?(ActiveRecord::Base)
207
+
208
+ ActiveRecord::Base.connected?
209
+ rescue StandardError
210
+ false
211
+ end
212
+
213
+ # Returns the configured adapter instance.
214
+ # Creates a new instance from adapter_class if not set.
215
+ # Defaults to Heroku adapter.
216
+ def adapter
217
+ @adapter ||= begin
218
+ klass = adapter_class || Adapters::Heroku
219
+ klass.new(config: self)
220
+ end
221
+ end
222
+
223
+ # Allow setting a pre-configured adapter instance or a symbol shortcut
224
+ # @param value [Symbol, Base, Class] :heroku, :kubernetes, an adapter instance, or adapter class
225
+ def adapter=(value)
226
+ @adapter = case value
227
+ when Symbol
228
+ resolve_adapter_symbol(value)
229
+ when Class
230
+ value.new(config: self)
231
+ else
232
+ value
233
+ end
234
+ end
235
+
236
+ # Maps adapter symbols to adapter classes
237
+ ADAPTER_SYMBOLS = {
238
+ heroku: 'SolidQueueAutoscaler::Adapters::Heroku',
239
+ kubernetes: 'SolidQueueAutoscaler::Adapters::Kubernetes',
240
+ k8s: 'SolidQueueAutoscaler::Adapters::Kubernetes'
241
+ }.freeze
242
+
243
+ private
244
+
245
+ def resolve_adapter_symbol(symbol)
246
+ class_name = ADAPTER_SYMBOLS[symbol]
247
+ unless class_name
248
+ raise ConfigurationError,
249
+ "Unknown adapter: #{symbol}. Valid options: #{ADAPTER_SYMBOLS.keys.join(', ')}"
250
+ end
251
+
252
+ klass = class_name.split('::').reduce(Object) { |mod, name| mod.const_get(name) }
253
+ klass.new(config: self)
254
+ end
255
+
256
+ def default_logger
257
+ if defined?(Rails) && Rails.logger
258
+ Rails.logger
259
+ else
260
+ Logger.new($stdout).tap do |logger|
261
+ logger.level = Logger::INFO
262
+ logger.formatter = proc do |severity, datetime, _progname, msg|
263
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] [SolidQueueAutoscaler] #{severity}: #{msg}\n"
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module SolidQueueAutoscaler
6
+ class CooldownTracker
7
+ TABLE_NAME = 'solid_queue_autoscaler_state'
8
+ DEFAULT_KEY = 'default'
9
+
10
+ attr_reader :key
11
+
12
+ def initialize(config: nil, key: DEFAULT_KEY)
13
+ @config = config || SolidQueueAutoscaler.config
14
+ @key = key
15
+ @table_exists = nil
16
+ end
17
+
18
+ def last_scale_up_at
19
+ return nil unless table_exists?
20
+
21
+ result = connection.select_value(<<~SQL)
22
+ SELECT last_scale_up_at FROM #{TABLE_NAME}
23
+ WHERE key = #{connection.quote(key)}
24
+ SQL
25
+ result ? Time.parse(result.to_s) : nil
26
+ rescue ArgumentError
27
+ nil
28
+ end
29
+
30
+ def last_scale_down_at
31
+ return nil unless table_exists?
32
+
33
+ result = connection.select_value(<<~SQL)
34
+ SELECT last_scale_down_at FROM #{TABLE_NAME}
35
+ WHERE key = #{connection.quote(key)}
36
+ SQL
37
+ result ? Time.parse(result.to_s) : nil
38
+ rescue ArgumentError
39
+ nil
40
+ end
41
+
42
+ def record_scale_up!
43
+ return false unless table_exists?
44
+
45
+ upsert_state(last_scale_up_at: Time.current)
46
+ true
47
+ end
48
+
49
+ def record_scale_down!
50
+ return false unless table_exists?
51
+
52
+ upsert_state(last_scale_down_at: Time.current)
53
+ true
54
+ end
55
+
56
+ def reset!
57
+ return false unless table_exists?
58
+
59
+ connection.execute(<<~SQL)
60
+ DELETE FROM #{TABLE_NAME} WHERE key = #{connection.quote(key)}
61
+ SQL
62
+ true
63
+ end
64
+
65
+ def cooldown_active_for_scale_up?
66
+ last = last_scale_up_at
67
+ return false unless last
68
+
69
+ Time.current - last < @config.effective_scale_up_cooldown
70
+ end
71
+
72
+ def cooldown_active_for_scale_down?
73
+ last = last_scale_down_at
74
+ return false unless last
75
+
76
+ Time.current - last < @config.effective_scale_down_cooldown
77
+ end
78
+
79
+ def scale_up_cooldown_remaining
80
+ last = last_scale_up_at
81
+ return 0 unless last
82
+
83
+ remaining = @config.effective_scale_up_cooldown - (Time.current - last)
84
+ [remaining, 0].max
85
+ end
86
+
87
+ def scale_down_cooldown_remaining
88
+ last = last_scale_down_at
89
+ return 0 unless last
90
+
91
+ remaining = @config.effective_scale_down_cooldown - (Time.current - last)
92
+ [remaining, 0].max
93
+ end
94
+
95
+ def table_exists?
96
+ return @table_exists unless @table_exists.nil?
97
+
98
+ @table_exists = connection.table_exists?(TABLE_NAME)
99
+ rescue StandardError
100
+ @table_exists = false
101
+ end
102
+
103
+ def state
104
+ return {} unless table_exists?
105
+
106
+ row = connection.select_one(<<~SQL)
107
+ SELECT last_scale_up_at, last_scale_down_at, updated_at
108
+ FROM #{TABLE_NAME}
109
+ WHERE key = #{connection.quote(key)}
110
+ SQL
111
+
112
+ return {} unless row
113
+
114
+ {
115
+ last_scale_up_at: row['last_scale_up_at'],
116
+ last_scale_down_at: row['last_scale_down_at'],
117
+ updated_at: row['updated_at']
118
+ }
119
+ end
120
+
121
+ private
122
+
123
+ def connection
124
+ @config.connection
125
+ end
126
+
127
+ def upsert_state(last_scale_up_at: nil, last_scale_down_at: nil)
128
+ now = Time.current
129
+ quoted_key = connection.quote(key)
130
+ quoted_now = connection.quote(now)
131
+
132
+ if last_scale_up_at
133
+ quoted_time = connection.quote(last_scale_up_at)
134
+ connection.execute(<<~SQL)
135
+ INSERT INTO #{TABLE_NAME} (key, last_scale_up_at, created_at, updated_at)
136
+ VALUES (#{quoted_key}, #{quoted_time}, #{quoted_now}, #{quoted_now})
137
+ ON CONFLICT (key) DO UPDATE SET
138
+ last_scale_up_at = EXCLUDED.last_scale_up_at,
139
+ updated_at = EXCLUDED.updated_at
140
+ SQL
141
+ elsif last_scale_down_at
142
+ quoted_time = connection.quote(last_scale_down_at)
143
+ connection.execute(<<~SQL)
144
+ INSERT INTO #{TABLE_NAME} (key, last_scale_down_at, created_at, updated_at)
145
+ VALUES (#{quoted_key}, #{quoted_time}, #{quoted_now}, #{quoted_now})
146
+ ON CONFLICT (key) DO UPDATE SET
147
+ last_scale_down_at = EXCLUDED.last_scale_down_at,
148
+ updated_at = EXCLUDED.updated_at
149
+ SQL
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller/railtie'
4
+ require 'action_view/railtie'
5
+
6
+ module SolidQueueAutoscaler
7
+ module Dashboard
8
+ # Rails engine that provides the autoscaler dashboard.
9
+ # Mount at /solid_queue_autoscaler or integrate with Mission Control.
10
+ #
11
+ # @example Mount in routes.rb
12
+ # mount SolidQueueAutoscaler::Dashboard::Engine => "/solid_queue_autoscaler"
13
+ #
14
+ # @example With authentication
15
+ # authenticate :user, ->(u) { u.admin? } do
16
+ # mount SolidQueueAutoscaler::Dashboard::Engine => "/solid_queue_autoscaler"
17
+ # end
18
+ class Engine < ::Rails::Engine
19
+ isolate_namespace SolidQueueAutoscaler::Dashboard
20
+
21
+ # Engine configuration
22
+ config.solid_queue_autoscaler_dashboard = ActiveSupport::OrderedOptions.new
23
+ config.solid_queue_autoscaler_dashboard.title = 'Solid Queue Autoscaler'
24
+
25
+ # Configure view paths
26
+ config.paths['app/views'] = File.expand_path('views', __dir__)
27
+
28
+ initializer 'solid_queue_autoscaler.dashboard.view_paths' do
29
+ ActiveSupport.on_load(:action_controller) do
30
+ append_view_path File.expand_path('views', __dir__)
31
+ end
32
+ end
33
+
34
+ initializer 'solid_queue_autoscaler.dashboard.integration' do
35
+ # Auto-integrate with Mission Control if available
36
+ ActiveSupport.on_load(:mission_control) do
37
+ # Register with Mission Control's tab system if available
38
+ end
39
+ end
40
+ end
41
+
42
+ # Application controller for dashboard
43
+ class ApplicationController < ActionController::Base
44
+ protect_from_forgery with: :exception
45
+
46
+ layout 'solid_queue_autoscaler/dashboard/application'
47
+
48
+ private
49
+
50
+ def autoscaler_status
51
+ @autoscaler_status ||= SolidQueueAutoscaler::Dashboard.status
52
+ end
53
+ helper_method :autoscaler_status
54
+
55
+ def events_available?
56
+ @events_available ||= SolidQueueAutoscaler::Dashboard.events_table_available?
57
+ end
58
+ helper_method :events_available?
59
+ end
60
+
61
+ # Main dashboard controller
62
+ class DashboardController < ApplicationController
63
+ def index
64
+ @status = autoscaler_status
65
+ @stats = SolidQueueAutoscaler::Dashboard.event_stats(since: 24.hours.ago)
66
+ @recent_events = SolidQueueAutoscaler::Dashboard.recent_events(limit: 10)
67
+ end
68
+ end
69
+
70
+ # Events controller
71
+ class EventsController < ApplicationController
72
+ def index
73
+ @worker_filter = params[:worker]
74
+ @events = SolidQueueAutoscaler::Dashboard.recent_events(
75
+ limit: params.fetch(:limit, 100).to_i,
76
+ worker_name: @worker_filter
77
+ )
78
+ @stats = SolidQueueAutoscaler::Dashboard.event_stats(
79
+ since: 24.hours.ago,
80
+ worker_name: @worker_filter
81
+ )
82
+ end
83
+ end
84
+
85
+ # Workers controller
86
+ class WorkersController < ApplicationController
87
+ def index
88
+ @workers = autoscaler_status
89
+ end
90
+
91
+ def show
92
+ worker_name = params[:id].to_sym
93
+ @worker = SolidQueueAutoscaler::Dashboard.worker_status(worker_name)
94
+ @events = SolidQueueAutoscaler::Dashboard.recent_events(
95
+ limit: 20,
96
+ worker_name: worker_name.to_s
97
+ )
98
+ end
99
+
100
+ def scale
101
+ worker_name = params[:id].to_sym
102
+ @result = SolidQueueAutoscaler.scale!(worker_name)
103
+ redirect_to worker_path(worker_name), notice: scale_notice(@result)
104
+ end
105
+
106
+ private
107
+
108
+ def scale_notice(result)
109
+ if result.success?
110
+ if result.scaled?
111
+ "Scaled from #{result.decision.from} to #{result.decision.to} workers"
112
+ elsif result.skipped?
113
+ "Skipped: #{result.skipped_reason}"
114
+ else
115
+ "No change needed: #{result.decision&.reason}"
116
+ end
117
+ else
118
+ "Error: #{result.error}"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ # Define routes for the engine
126
+ SolidQueueAutoscaler::Dashboard::Engine.routes.draw do
127
+ root to: 'dashboard#index'
128
+
129
+ resources :events, only: [:index]
130
+
131
+ resources :workers, only: %i[index show] do
132
+ member do
133
+ post :scale
134
+ end
135
+ end
136
+ end