solid_queue_heroku_autoscaler 0.1.0 → 0.2.1

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.
@@ -109,7 +109,7 @@ module SolidQueueHerokuAutoscaler
109
109
 
110
110
  desc 'Reset cooldown state for a worker (or all if WORKER=all). Use WORKER=name'
111
111
  task reset_cooldown: :environment do
112
- worker_name = ENV['WORKER']&.to_sym
112
+ worker_name = ENV.fetch('WORKER', nil)&.to_sym
113
113
 
114
114
  if worker_name == :all || worker_name.nil?
115
115
  # Reset all workers
@@ -129,6 +129,36 @@ module SolidQueueHerokuAutoscaler
129
129
  end
130
130
  end
131
131
 
132
+ desc 'Show recent scale events. Use LIMIT=n and WORKER=name'
133
+ task events: :environment do
134
+ worker_name = ENV.fetch('WORKER', nil)
135
+ limit = (ENV['LIMIT'] || 20).to_i
136
+
137
+ events = SolidQueueHerokuAutoscaler::ScaleEvent.recent(limit: limit, worker_name: worker_name)
138
+
139
+ if events.empty?
140
+ puts 'No events found'
141
+ puts '(Make sure to run: rails generate solid_queue_heroku_autoscaler:dashboard)'
142
+ else
143
+ puts "Recent Scale Events#{" for #{worker_name}" if worker_name} (#{events.size}):"
144
+ puts '-' * 100
145
+ events.each do |event|
146
+ action = event.action.ljust(10)
147
+ workers = "#{event.from_workers}->#{event.to_workers}".ljust(8)
148
+ dry_run = event.dry_run ? ' [DRY RUN]' : ''
149
+ time = event.created_at.strftime('%Y-%m-%d %H:%M:%S')
150
+ puts "#{time} | #{event.worker_name.ljust(15)} | #{action} | #{workers} | #{event.reason}#{dry_run}"
151
+ end
152
+ end
153
+ end
154
+
155
+ desc 'Cleanup old scale events. Use KEEP_DAYS=n (default: 30)'
156
+ task cleanup_events: :environment do
157
+ keep_days = (ENV['KEEP_DAYS'] || 30).to_i
158
+ SolidQueueHerokuAutoscaler::ScaleEvent.cleanup!(keep_days: keep_days)
159
+ puts "Cleaned up events older than #{keep_days} days"
160
+ end
161
+
132
162
  def print_scale_result(result, worker_name)
133
163
  prefix = worker_name == :default ? '' : "[#{worker_name}] "
134
164
  if result.success?
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueHerokuAutoscaler
4
+ # Lightweight model for recording autoscaler events.
5
+ # Does not inherit from ActiveRecord to avoid requiring it as a dependency.
6
+ # Uses raw SQL for compatibility with any database connection.
7
+ class ScaleEvent
8
+ TABLE_NAME = 'solid_queue_autoscaler_events'
9
+
10
+ ACTIONS = %w[scale_up scale_down no_change skipped error].freeze
11
+
12
+ attr_reader :id, :worker_name, :action, :from_workers, :to_workers,
13
+ :reason, :queue_depth, :latency_seconds, :metrics_json,
14
+ :dry_run, :created_at
15
+
16
+ def initialize(attrs = {})
17
+ @id = attrs[:id]
18
+ @worker_name = attrs[:worker_name]
19
+ @action = attrs[:action]
20
+ @from_workers = attrs[:from_workers]
21
+ @to_workers = attrs[:to_workers]
22
+ @reason = attrs[:reason]
23
+ @queue_depth = attrs[:queue_depth]
24
+ @latency_seconds = attrs[:latency_seconds]
25
+ @metrics_json = attrs[:metrics_json]
26
+ @dry_run = attrs[:dry_run]
27
+ @created_at = attrs[:created_at]
28
+ end
29
+
30
+ def scaled?
31
+ %w[scale_up scale_down].include?(action)
32
+ end
33
+
34
+ def scale_up?
35
+ action == 'scale_up'
36
+ end
37
+
38
+ def scale_down?
39
+ action == 'scale_down'
40
+ end
41
+
42
+ def metrics
43
+ return nil unless metrics_json
44
+
45
+ JSON.parse(metrics_json, symbolize_names: true)
46
+ rescue JSON::ParserError
47
+ nil
48
+ end
49
+
50
+ class << self
51
+ # Creates a new scale event record.
52
+ # @param attrs [Hash] Event attributes
53
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
54
+ # @return [ScaleEvent] The created event
55
+ def create!(attrs, connection: nil)
56
+ conn = connection || default_connection
57
+ return nil unless table_exists?(conn)
58
+
59
+ now = Time.current
60
+ sql = <<~SQL
61
+ INSERT INTO #{TABLE_NAME}
62
+ (worker_name, action, from_workers, to_workers, reason,
63
+ queue_depth, latency_seconds, metrics_json, dry_run, created_at)
64
+ VALUES
65
+ (#{conn.quote(attrs[:worker_name])},
66
+ #{conn.quote(attrs[:action])},
67
+ #{conn.quote(attrs[:from_workers])},
68
+ #{conn.quote(attrs[:to_workers])},
69
+ #{conn.quote(attrs[:reason])},
70
+ #{conn.quote(attrs[:queue_depth])},
71
+ #{conn.quote(attrs[:latency_seconds])},
72
+ #{conn.quote(attrs[:metrics_json])},
73
+ #{conn.quote(attrs[:dry_run])},
74
+ #{conn.quote(now)})
75
+ RETURNING id
76
+ SQL
77
+
78
+ result = conn.execute(sql)
79
+ id = result.first&.fetch('id', nil)
80
+
81
+ new(attrs.merge(id: id, created_at: now))
82
+ rescue StandardError => e
83
+ # Log but don't fail if event recording fails
84
+ Rails.logger.warn("[Autoscaler] Failed to record event: #{e.message}") if defined?(Rails)
85
+ nil
86
+ end
87
+
88
+ # Finds recent events.
89
+ # @param limit [Integer] Maximum number of events to return
90
+ # @param worker_name [String, nil] Filter by worker name
91
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
92
+ # @return [Array<ScaleEvent>] Array of events
93
+ def recent(limit: 50, worker_name: nil, connection: nil)
94
+ conn = connection || default_connection
95
+ return [] unless table_exists?(conn)
96
+
97
+ filter = worker_name ? "WHERE worker_name = #{conn.quote(worker_name)}" : ''
98
+
99
+ sql = <<~SQL
100
+ SELECT id, worker_name, action, from_workers, to_workers, reason,
101
+ queue_depth, latency_seconds, metrics_json, dry_run, created_at
102
+ FROM #{TABLE_NAME}
103
+ #{filter}
104
+ ORDER BY created_at DESC
105
+ LIMIT #{limit.to_i}
106
+ SQL
107
+
108
+ conn.select_all(sql).map { |row| from_row(row) }
109
+ rescue StandardError
110
+ []
111
+ end
112
+
113
+ # Finds events by action type.
114
+ # @param action [String] Action type (scale_up, scale_down, etc.)
115
+ # @param limit [Integer] Maximum number of events
116
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
117
+ # @return [Array<ScaleEvent>] Array of events
118
+ def by_action(action, limit: 50, connection: nil)
119
+ conn = connection || default_connection
120
+ return [] unless table_exists?(conn)
121
+
122
+ sql = <<~SQL
123
+ SELECT id, worker_name, action, from_workers, to_workers, reason,
124
+ queue_depth, latency_seconds, metrics_json, dry_run, created_at
125
+ FROM #{TABLE_NAME}
126
+ WHERE action = #{conn.quote(action)}
127
+ ORDER BY created_at DESC
128
+ LIMIT #{limit.to_i}
129
+ SQL
130
+
131
+ conn.select_all(sql).map { |row| from_row(row) }
132
+ rescue StandardError
133
+ []
134
+ end
135
+
136
+ # Gets event statistics for a time period.
137
+ # @param since [Time] Start time for statistics
138
+ # @param worker_name [String, nil] Filter by worker name
139
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
140
+ # @return [Hash] Statistics hash
141
+ def stats(since: 24.hours.ago, worker_name: nil, connection: nil)
142
+ conn = connection || default_connection
143
+ return default_stats unless table_exists?(conn)
144
+
145
+ worker_filter = worker_name ? "AND worker_name = #{conn.quote(worker_name)}" : ''
146
+
147
+ sql = <<~SQL
148
+ SELECT
149
+ action,
150
+ COUNT(*) as count,
151
+ AVG(queue_depth) as avg_queue_depth,
152
+ AVG(latency_seconds) as avg_latency
153
+ FROM #{TABLE_NAME}
154
+ WHERE created_at >= #{conn.quote(since)}
155
+ #{worker_filter}
156
+ GROUP BY action
157
+ SQL
158
+
159
+ results = conn.select_all(sql).to_a
160
+ build_stats(results)
161
+ rescue StandardError
162
+ default_stats
163
+ end
164
+
165
+ # Cleans up old events.
166
+ # @param keep_days [Integer] Number of days to keep
167
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
168
+ # @return [Integer] Number of deleted records
169
+ def cleanup!(keep_days: 30, connection: nil)
170
+ conn = connection || default_connection
171
+ return 0 unless table_exists?(conn)
172
+
173
+ cutoff = Time.current - keep_days.days
174
+
175
+ sql = <<~SQL
176
+ DELETE FROM #{TABLE_NAME}
177
+ WHERE created_at < #{conn.quote(cutoff)}
178
+ SQL
179
+
180
+ result = conn.execute(sql)
181
+ result.cmd_tuples
182
+ rescue StandardError
183
+ 0
184
+ end
185
+
186
+ # Checks if the events table exists.
187
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
188
+ # @return [Boolean] True if table exists
189
+ def table_exists?(connection = nil)
190
+ conn = connection || default_connection
191
+ conn.table_exists?(TABLE_NAME)
192
+ rescue StandardError
193
+ false
194
+ end
195
+
196
+ # Counts events in a time period.
197
+ # @param since [Time] Start time
198
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
199
+ # @return [Integer] Event count
200
+ def count(since: nil, connection: nil)
201
+ conn = connection || default_connection
202
+ return 0 unless table_exists?(conn)
203
+
204
+ time_filter = since ? "WHERE created_at >= #{conn.quote(since)}" : ''
205
+
206
+ sql = "SELECT COUNT(*) FROM #{TABLE_NAME} #{time_filter}"
207
+ conn.select_value(sql).to_i
208
+ rescue StandardError
209
+ 0
210
+ end
211
+
212
+ private
213
+
214
+ def default_connection
215
+ ActiveRecord::Base.connection
216
+ end
217
+
218
+ def from_row(row)
219
+ new(
220
+ id: row['id'],
221
+ worker_name: row['worker_name'],
222
+ action: row['action'],
223
+ from_workers: row['from_workers'].to_i,
224
+ to_workers: row['to_workers'].to_i,
225
+ reason: row['reason'],
226
+ queue_depth: row['queue_depth'].to_i,
227
+ latency_seconds: row['latency_seconds'].to_f,
228
+ metrics_json: row['metrics_json'],
229
+ dry_run: parse_boolean(row['dry_run']),
230
+ created_at: parse_time(row['created_at'])
231
+ )
232
+ end
233
+
234
+ def parse_boolean(value)
235
+ case value
236
+ when true, 't', 'true', '1', 1
237
+ true
238
+ else
239
+ false
240
+ end
241
+ end
242
+
243
+ def parse_time(value)
244
+ case value
245
+ when Time, DateTime
246
+ value.to_time
247
+ when String
248
+ Time.parse(value)
249
+ else
250
+ value
251
+ end
252
+ end
253
+
254
+ def default_stats
255
+ {
256
+ total: 0,
257
+ scale_up_count: 0,
258
+ scale_down_count: 0,
259
+ no_change_count: 0,
260
+ skipped_count: 0,
261
+ error_count: 0,
262
+ avg_queue_depth: 0,
263
+ avg_latency: 0
264
+ }
265
+ end
266
+
267
+ def build_stats(results)
268
+ stats = default_stats
269
+
270
+ results.each do |row|
271
+ action = row['action']
272
+ count = row['count'].to_i
273
+
274
+ stats[:total] += count
275
+ stats[:"#{action}_count"] = count
276
+
277
+ # Use weighted average for overall metrics
278
+ stats[:avg_queue_depth] += row['avg_queue_depth'].to_f * count if row['avg_queue_depth']
279
+ stats[:avg_latency] += row['avg_latency'].to_f * count if row['avg_latency']
280
+ end
281
+
282
+ # Calculate averages
283
+ if stats[:total].positive?
284
+ stats[:avg_queue_depth] /= stats[:total]
285
+ stats[:avg_latency] /= stats[:total]
286
+ end
287
+
288
+ stats
289
+ end
290
+ end
291
+ end
292
+ end
@@ -126,6 +126,7 @@ module SolidQueueHerokuAutoscaler
126
126
  def apply_decision(decision, metrics)
127
127
  @adapter.scale(decision.to)
128
128
  record_scale_time(decision)
129
+ record_scale_event(decision, metrics)
129
130
 
130
131
  log_scale_action(decision)
131
132
 
@@ -190,6 +191,9 @@ module SolidQueueHerokuAutoscaler
190
191
  end
191
192
 
192
193
  def success_result(decision, metrics)
194
+ # Record no_change events if configured
195
+ record_scale_event(decision, metrics) if decision&.no_change? && @config.record_all_events?
196
+
193
197
  ScaleResult.new(
194
198
  success: true,
195
199
  decision: decision,
@@ -201,6 +205,9 @@ module SolidQueueHerokuAutoscaler
201
205
  def skipped_result(reason, decision: nil, metrics: nil)
202
206
  logger.debug("[Autoscaler] Skipped: #{reason}")
203
207
 
208
+ # Record skipped events
209
+ record_skipped_event(reason, decision, metrics)
210
+
204
211
  ScaleResult.new(
205
212
  success: true,
206
213
  decision: decision,
@@ -213,6 +220,9 @@ module SolidQueueHerokuAutoscaler
213
220
  def error_result(error)
214
221
  logger.error("[Autoscaler] Error: #{error.class}: #{error.message}")
215
222
 
223
+ # Record error events
224
+ record_error_event(error)
225
+
216
226
  ScaleResult.new(
217
227
  success: false,
218
228
  error: error,
@@ -223,5 +233,62 @@ module SolidQueueHerokuAutoscaler
223
233
  def logger
224
234
  @config.logger
225
235
  end
236
+
237
+ def record_scale_event(decision, metrics)
238
+ return unless @config.record_events?
239
+
240
+ ScaleEvent.create!(
241
+ {
242
+ worker_name: @config.name.to_s,
243
+ action: decision.action.to_s,
244
+ from_workers: decision.from,
245
+ to_workers: decision.to,
246
+ reason: decision.reason,
247
+ queue_depth: metrics&.queue_depth || 0,
248
+ latency_seconds: metrics&.oldest_job_age_seconds || 0.0,
249
+ metrics_json: metrics&.to_h&.to_json,
250
+ dry_run: @config.dry_run?
251
+ },
252
+ connection: @config.connection
253
+ )
254
+ end
255
+
256
+ def record_skipped_event(reason, decision, metrics)
257
+ return unless @config.record_events?
258
+
259
+ ScaleEvent.create!(
260
+ {
261
+ worker_name: @config.name.to_s,
262
+ action: 'skipped',
263
+ from_workers: decision&.from || 0,
264
+ to_workers: decision&.to || 0,
265
+ reason: reason,
266
+ queue_depth: metrics&.queue_depth || 0,
267
+ latency_seconds: metrics&.oldest_job_age_seconds || 0.0,
268
+ metrics_json: metrics&.to_h&.to_json,
269
+ dry_run: @config.dry_run?
270
+ },
271
+ connection: @config.connection
272
+ )
273
+ end
274
+
275
+ def record_error_event(error)
276
+ return unless @config.record_events?
277
+
278
+ ScaleEvent.create!(
279
+ {
280
+ worker_name: @config.name.to_s,
281
+ action: 'error',
282
+ from_workers: 0,
283
+ to_workers: 0,
284
+ reason: "#{error.class}: #{error.message}",
285
+ queue_depth: 0,
286
+ latency_seconds: 0.0,
287
+ metrics_json: nil,
288
+ dry_run: @config.dry_run?
289
+ },
290
+ connection: @config.connection
291
+ )
292
+ end
226
293
  end
227
294
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidQueueHerokuAutoscaler
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.1'
5
5
  end
@@ -12,6 +12,7 @@ require_relative 'solid_queue_heroku_autoscaler/advisory_lock'
12
12
  require_relative 'solid_queue_heroku_autoscaler/metrics'
13
13
  require_relative 'solid_queue_heroku_autoscaler/decision_engine'
14
14
  require_relative 'solid_queue_heroku_autoscaler/cooldown_tracker'
15
+ require_relative 'solid_queue_heroku_autoscaler/scale_event'
15
16
  require_relative 'solid_queue_heroku_autoscaler/scaler'
16
17
 
17
18
  module SolidQueueHerokuAutoscaler
@@ -102,5 +103,6 @@ module SolidQueueHerokuAutoscaler
102
103
  end
103
104
 
104
105
  require_relative 'solid_queue_heroku_autoscaler/railtie' if defined?(Rails::Railtie)
106
+ require_relative 'solid_queue_heroku_autoscaler/dashboard'
105
107
 
106
108
  require_relative 'solid_queue_heroku_autoscaler/autoscale_job' if defined?(ActiveJob::Base)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue_heroku_autoscaler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
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-15 00:00:00.000000000 Z
11
+ date: 2026-01-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -119,9 +119,11 @@ files:
119
119
  - CHANGELOG.md
120
120
  - LICENSE.txt
121
121
  - README.md
122
+ - lib/generators/solid_queue_heroku_autoscaler/dashboard_generator.rb
122
123
  - lib/generators/solid_queue_heroku_autoscaler/install_generator.rb
123
124
  - lib/generators/solid_queue_heroku_autoscaler/migration_generator.rb
124
125
  - lib/generators/solid_queue_heroku_autoscaler/templates/README
126
+ - lib/generators/solid_queue_heroku_autoscaler/templates/create_solid_queue_autoscaler_events.rb.erb
125
127
  - lib/generators/solid_queue_heroku_autoscaler/templates/create_solid_queue_autoscaler_state.rb.erb
126
128
  - lib/generators/solid_queue_heroku_autoscaler/templates/initializer.rb
127
129
  - lib/solid_queue_heroku_autoscaler.rb
@@ -133,10 +135,18 @@ files:
133
135
  - lib/solid_queue_heroku_autoscaler/autoscale_job.rb
134
136
  - lib/solid_queue_heroku_autoscaler/configuration.rb
135
137
  - lib/solid_queue_heroku_autoscaler/cooldown_tracker.rb
138
+ - lib/solid_queue_heroku_autoscaler/dashboard.rb
139
+ - lib/solid_queue_heroku_autoscaler/dashboard/engine.rb
140
+ - lib/solid_queue_heroku_autoscaler/dashboard/views/layouts/solid_queue_heroku_autoscaler/dashboard/application.html.erb
141
+ - lib/solid_queue_heroku_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/dashboard/index.html.erb
142
+ - lib/solid_queue_heroku_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/events/index.html.erb
143
+ - lib/solid_queue_heroku_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/workers/index.html.erb
144
+ - lib/solid_queue_heroku_autoscaler/dashboard/views/solid_queue_heroku_autoscaler/dashboard/workers/show.html.erb
136
145
  - lib/solid_queue_heroku_autoscaler/decision_engine.rb
137
146
  - lib/solid_queue_heroku_autoscaler/errors.rb
138
147
  - lib/solid_queue_heroku_autoscaler/metrics.rb
139
148
  - lib/solid_queue_heroku_autoscaler/railtie.rb
149
+ - lib/solid_queue_heroku_autoscaler/scale_event.rb
140
150
  - lib/solid_queue_heroku_autoscaler/scaler.rb
141
151
  - lib/solid_queue_heroku_autoscaler/version.rb
142
152
  homepage: https://github.com/reillyse/solid_queue_heroku_autoscaler