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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +189 -0
- data/LICENSE.txt +21 -0
- data/README.md +553 -0
- data/lib/generators/solid_queue_autoscaler/dashboard_generator.rb +54 -0
- data/lib/generators/solid_queue_autoscaler/install_generator.rb +21 -0
- data/lib/generators/solid_queue_autoscaler/migration_generator.rb +29 -0
- data/lib/generators/solid_queue_autoscaler/templates/README +41 -0
- data/lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_events.rb.erb +24 -0
- data/lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_state.rb.erb +15 -0
- data/lib/generators/solid_queue_autoscaler/templates/initializer.rb +58 -0
- data/lib/solid_queue_autoscaler/adapters/base.rb +102 -0
- data/lib/solid_queue_autoscaler/adapters/heroku.rb +93 -0
- data/lib/solid_queue_autoscaler/adapters/kubernetes.rb +158 -0
- data/lib/solid_queue_autoscaler/adapters.rb +57 -0
- data/lib/solid_queue_autoscaler/advisory_lock.rb +71 -0
- data/lib/solid_queue_autoscaler/autoscale_job.rb +71 -0
- data/lib/solid_queue_autoscaler/configuration.rb +269 -0
- data/lib/solid_queue_autoscaler/cooldown_tracker.rb +153 -0
- data/lib/solid_queue_autoscaler/dashboard/engine.rb +136 -0
- data/lib/solid_queue_autoscaler/dashboard/views/layouts/solid_queue_heroku_autoscaler/dashboard/application.html.erb +206 -0
- data/lib/solid_queue_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/dashboard/index.html.erb +138 -0
- data/lib/solid_queue_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/events/index.html.erb +102 -0
- data/lib/solid_queue_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/workers/index.html.erb +106 -0
- data/lib/solid_queue_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/workers/show.html.erb +209 -0
- data/lib/solid_queue_autoscaler/dashboard.rb +99 -0
- data/lib/solid_queue_autoscaler/decision_engine.rb +228 -0
- data/lib/solid_queue_autoscaler/errors.rb +44 -0
- data/lib/solid_queue_autoscaler/metrics.rb +172 -0
- data/lib/solid_queue_autoscaler/railtie.rb +179 -0
- data/lib/solid_queue_autoscaler/scale_event.rb +292 -0
- data/lib/solid_queue_autoscaler/scaler.rb +294 -0
- data/lib/solid_queue_autoscaler/version.rb +5 -0
- data/lib/solid_queue_autoscaler.rb +108 -0
- 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
|