solid_queue_heroku_autoscaler 0.1.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.
@@ -0,0 +1,41 @@
1
+ ===============================================================================
2
+
3
+ SolidQueueHerokuAutoscaler has been installed!
4
+
5
+ Next steps:
6
+
7
+ 1. Set environment variables:
8
+ - HEROKU_API_KEY: Generate with `heroku authorizations:create -d "Solid Queue Autoscaler"`
9
+ - HEROKU_APP_NAME: Your Heroku app name
10
+
11
+ 2. Run the migration generator for persistent cooldown tracking:
12
+
13
+ rails generate solid_queue_heroku_autoscaler:migration
14
+ rails db:migrate
15
+
16
+ 3. Review config/initializers/solid_queue_autoscaler.rb and adjust thresholds
17
+
18
+ 4. Add the recurring job to config/recurring.yml:
19
+
20
+ autoscaler:
21
+ class: SolidQueueHerokuAutoscaler::AutoscaleJob
22
+ queue: autoscaler
23
+ schedule: every 30 seconds
24
+
25
+ 5. Configure a dedicated queue in config/queue.yml:
26
+
27
+ queues:
28
+ - autoscaler
29
+ - default
30
+
31
+ workers:
32
+ - queues: [autoscaler]
33
+ threads: 1
34
+ - queues: [default]
35
+ threads: 5
36
+
37
+ 6. Test with dry_run mode before enabling in production
38
+
39
+ For more information, see: https://github.com/reillyse/solid_queue_heroku_autoscaler
40
+
41
+ ===============================================================================
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSolidQueueAutoscalerState < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :solid_queue_autoscaler_state do |t|
6
+ t.string :key, null: false
7
+ t.datetime :last_scale_up_at
8
+ t.datetime :last_scale_down_at
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :solid_queue_autoscaler_state, :key, unique: true
14
+ end
15
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ SolidQueueHerokuAutoscaler.configure do |config|
4
+ # Required: Heroku settings
5
+ # Generate an API key with: heroku authorizations:create -d "Solid Queue Autoscaler"
6
+ config.heroku_api_key = ENV.fetch('HEROKU_API_KEY', nil)
7
+ config.heroku_app_name = ENV.fetch('HEROKU_APP_NAME', nil)
8
+ config.process_type = 'worker'
9
+
10
+ # Worker limits
11
+ config.min_workers = 1
12
+ config.max_workers = 10
13
+
14
+ # Scaling strategy:
15
+ # :fixed - adds/removes fixed increment/decrement (default)
16
+ # :proportional - calculates workers based on jobs/latency over threshold
17
+ config.scaling_strategy = :fixed
18
+
19
+ # Scale-up thresholds (trigger scale up when ANY threshold is exceeded)
20
+ config.scale_up_queue_depth = 100 # Jobs waiting in queue
21
+ config.scale_up_latency_seconds = 300 # Age of oldest job (5 minutes)
22
+ config.scale_up_increment = 1 # Workers to add per scale event (fixed strategy)
23
+
24
+ # Proportional scaling settings (when scaling_strategy is :proportional)
25
+ # config.scale_up_jobs_per_worker = 50 # Add 1 worker per 50 jobs over threshold
26
+ # config.scale_up_latency_per_worker = 60 # Add 1 worker per 60s over threshold
27
+ # config.scale_down_jobs_per_worker = 50 # Remove 1 worker per 50 jobs under capacity
28
+
29
+ # Scale-down thresholds (trigger scale down when ALL thresholds are met)
30
+ config.scale_down_queue_depth = 10 # Jobs waiting in queue
31
+ config.scale_down_latency_seconds = 30 # Age of oldest job
32
+ config.scale_down_decrement = 1 # Workers to remove per scale event
33
+
34
+ # Cooldowns (prevent rapid scaling)
35
+ config.cooldown_seconds = 120 # Default cooldown for both directions
36
+ # config.scale_up_cooldown_seconds = 60 # Override for scale up
37
+ # config.scale_down_cooldown_seconds = 180 # Override for scale down
38
+
39
+ # Safety settings
40
+ config.dry_run = Rails.env.development? # Safe default for development
41
+ config.enabled = Rails.env.production? # Only enable in production
42
+
43
+ # Cooldown persistence (survives dyno restarts)
44
+ # Requires running: rails generate solid_queue_heroku_autoscaler:migration
45
+ config.persist_cooldowns = true
46
+
47
+ # Optional: Filter to specific queues (nil = all queues)
48
+ # config.queues = ['default', 'mailers']
49
+
50
+ # Optional: Custom logger
51
+ # config.logger = Rails.logger
52
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueHerokuAutoscaler
4
+ module Adapters
5
+ # Base class for infrastructure platform adapters.
6
+ #
7
+ # Subclasses must implement:
8
+ # - #current_workers - returns Integer count of current workers
9
+ # - #scale(quantity) - scales to quantity workers, returns new count
10
+ #
11
+ # Subclasses may override:
12
+ # - #name - human-readable adapter name (default: class name)
13
+ # - #configured? - whether adapter has valid configuration (default: configuration_errors.empty?)
14
+ # - #configuration_errors - array of configuration error messages (default: [])
15
+ #
16
+ # @example Creating a custom adapter
17
+ # class MyAdapter < SolidQueueHerokuAutoscaler::Adapters::Base
18
+ # def current_workers
19
+ # # Return current worker count
20
+ # end
21
+ #
22
+ # def scale(quantity)
23
+ # return quantity if dry_run?
24
+ # # Scale to quantity workers
25
+ # quantity
26
+ # end
27
+ #
28
+ # def configuration_errors
29
+ # errors = []
30
+ # errors << 'my_setting is required' if config.my_setting.nil?
31
+ # errors
32
+ # end
33
+ # end
34
+ class Base
35
+ # @param config [Configuration] the autoscaler configuration
36
+ def initialize(config:)
37
+ @config = config
38
+ end
39
+
40
+ # Returns the current number of workers.
41
+ #
42
+ # @return [Integer] current worker count
43
+ # @raise [NotImplementedError] if not implemented by subclass
44
+ def current_workers
45
+ raise NotImplementedError, "#{self.class.name} must implement #current_workers"
46
+ end
47
+
48
+ # Scales to the specified number of workers.
49
+ #
50
+ # @param quantity [Integer] desired worker count
51
+ # @return [Integer] the new worker count
52
+ # @raise [NotImplementedError] if not implemented by subclass
53
+ def scale(quantity)
54
+ raise NotImplementedError, "#{self.class.name} must implement #scale(quantity)"
55
+ end
56
+
57
+ # Human-readable name of the adapter for logging.
58
+ #
59
+ # @return [String] adapter name
60
+ def name
61
+ self.class.name.split('::').last
62
+ end
63
+
64
+ # Checks if the adapter is properly configured.
65
+ #
66
+ # @return [Boolean] true if configured correctly
67
+ def configured?
68
+ configuration_errors.empty?
69
+ end
70
+
71
+ # Returns an array of configuration error messages.
72
+ #
73
+ # @return [Array<String>] error messages (empty if valid)
74
+ def configuration_errors
75
+ []
76
+ end
77
+
78
+ protected
79
+
80
+ # @return [Configuration] the autoscaler configuration
81
+ attr_reader :config
82
+
83
+ # @return [Logger] the configured logger
84
+ def logger
85
+ config.logger
86
+ end
87
+
88
+ # @return [Boolean] true if dry-run mode is enabled
89
+ def dry_run?
90
+ config.dry_run?
91
+ end
92
+
93
+ # Logs a dry-run message at info level.
94
+ #
95
+ # @param message [String] the message to log
96
+ # @return [void]
97
+ def log_dry_run(message)
98
+ logger.info("[DRY RUN] #{message}")
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'platform-api'
4
+
5
+ module SolidQueueHerokuAutoscaler
6
+ module Adapters
7
+ # Heroku adapter using the Heroku Platform API.
8
+ #
9
+ # This is the default adapter for the autoscaler.
10
+ #
11
+ # Configuration:
12
+ # - heroku_api_key: Heroku API key (or HEROKU_API_KEY env var)
13
+ # - heroku_app_name: Heroku app name (or HEROKU_APP_NAME env var)
14
+ # - process_type: Dyno process type to scale (default: 'worker')
15
+ #
16
+ # @example
17
+ # SolidQueueHerokuAutoscaler.configure do |config|
18
+ # config.heroku_api_key = ENV['HEROKU_API_KEY']
19
+ # config.heroku_app_name = ENV['HEROKU_APP_NAME']
20
+ # config.process_type = 'worker'
21
+ # end
22
+ class Heroku < Base
23
+ def current_workers
24
+ formation = client.formation.info(app_name, process_type)
25
+ formation['quantity']
26
+ rescue Excon::Error => e
27
+ raise HerokuAPIError.new(
28
+ "Failed to get formation info: #{e.message}",
29
+ status_code: e.respond_to?(:response) ? e.response&.status : nil,
30
+ response_body: e.respond_to?(:response) ? e.response&.body : nil
31
+ )
32
+ end
33
+
34
+ def scale(quantity)
35
+ if dry_run?
36
+ log_dry_run("Would scale #{process_type} to #{quantity} dynos")
37
+ return quantity
38
+ end
39
+
40
+ client.formation.update(app_name, process_type, { quantity: quantity })
41
+ quantity
42
+ rescue Excon::Error => e
43
+ raise HerokuAPIError.new(
44
+ "Failed to scale #{process_type} to #{quantity}: #{e.message}",
45
+ status_code: e.respond_to?(:response) ? e.response&.status : nil,
46
+ response_body: e.respond_to?(:response) ? e.response&.body : nil
47
+ )
48
+ end
49
+
50
+ def name
51
+ 'Heroku'
52
+ end
53
+
54
+ def configuration_errors
55
+ errors = []
56
+ errors << 'heroku_api_key is required' if api_key.nil? || api_key.empty?
57
+ errors << 'heroku_app_name is required' if app_name.nil? || app_name.empty?
58
+ errors
59
+ end
60
+
61
+ # Returns the list of all formations for the app.
62
+ #
63
+ # @return [Array<Hash>] formation info hashes
64
+ def formation_list
65
+ client.formation.list(app_name)
66
+ rescue Excon::Error => e
67
+ raise HerokuAPIError.new(
68
+ "Failed to list formations: #{e.message}",
69
+ status_code: e.respond_to?(:response) ? e.response&.status : nil,
70
+ response_body: e.respond_to?(:response) ? e.response&.body : nil
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def client
77
+ @client ||= PlatformAPI.connect_oauth(api_key)
78
+ end
79
+
80
+ def api_key
81
+ config.heroku_api_key
82
+ end
83
+
84
+ def app_name
85
+ config.heroku_app_name
86
+ end
87
+
88
+ def process_type
89
+ config.process_type
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueHerokuAutoscaler
4
+ module Adapters
5
+ # Kubernetes adapter for scaling Deployment replicas.
6
+ #
7
+ # Uses the kubeclient gem to interact with the Kubernetes API.
8
+ # Supports both in-cluster configuration and kubeconfig file authentication.
9
+ #
10
+ # Configuration:
11
+ # - kubernetes_deployment: Name of the Deployment to scale (or K8S_DEPLOYMENT env var)
12
+ # - kubernetes_namespace: Namespace of the Deployment (default: 'default', or K8S_NAMESPACE env var)
13
+ # - kubernetes_context: Kubeconfig context to use (optional, for kubeconfig auth)
14
+ #
15
+ # @example In-cluster configuration (running inside a pod)
16
+ # SolidQueueHerokuAutoscaler.configure do |config|
17
+ # config.adapter_class = SolidQueueHerokuAutoscaler::Adapters::Kubernetes
18
+ # config.kubernetes_deployment = 'my-worker'
19
+ # config.kubernetes_namespace = 'production'
20
+ # end
21
+ #
22
+ # @example Using kubeconfig (local development)
23
+ # SolidQueueHerokuAutoscaler.configure do |config|
24
+ # config.adapter_class = SolidQueueHerokuAutoscaler::Adapters::Kubernetes
25
+ # config.kubernetes_deployment = 'my-worker'
26
+ # config.kubernetes_namespace = 'default'
27
+ # config.kubernetes_context = 'my-cluster-context'
28
+ # end
29
+ class Kubernetes < Base
30
+ # Kubernetes API path for apps/v1 group
31
+ APPS_API_VERSION = 'apis/apps/v1'
32
+
33
+ def current_workers
34
+ deployment = apps_client.get_deployment(deployment_name, namespace)
35
+ deployment.spec.replicas
36
+ rescue StandardError => e
37
+ raise KubernetesAPIError.new("Failed to get deployment info: #{e.message}", original_error: e)
38
+ end
39
+
40
+ def scale(quantity)
41
+ if dry_run?
42
+ log_dry_run("Would scale deployment #{deployment_name} to #{quantity} replicas in namespace #{namespace}")
43
+ return quantity
44
+ end
45
+
46
+ patch_body = { spec: { replicas: quantity } }
47
+ apps_client.patch_deployment(deployment_name, patch_body, namespace)
48
+ quantity
49
+ rescue StandardError => e
50
+ raise KubernetesAPIError.new("Failed to scale deployment #{deployment_name} to #{quantity}: #{e.message}",
51
+ original_error: e)
52
+ end
53
+
54
+ def name
55
+ 'Kubernetes'
56
+ end
57
+
58
+ def configuration_errors
59
+ errors = []
60
+ errors << 'kubernetes_deployment is required' if deployment_name.nil? || deployment_name.empty?
61
+ errors << 'kubernetes_namespace is required' if namespace.nil? || namespace.empty?
62
+ errors
63
+ end
64
+
65
+ private
66
+
67
+ def apps_client
68
+ @apps_client ||= build_apps_client
69
+ end
70
+
71
+ def build_apps_client
72
+ require 'kubeclient'
73
+
74
+ if in_cluster?
75
+ build_in_cluster_client
76
+ else
77
+ build_kubeconfig_client
78
+ end
79
+ end
80
+
81
+ def build_in_cluster_client
82
+ # In-cluster configuration reads from mounted service account
83
+ auth_options = {
84
+ bearer_token_file: '/var/run/secrets/kubernetes.io/serviceaccount/token'
85
+ }
86
+
87
+ ssl_options = {
88
+ ca_file: '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt',
89
+ verify_ssl: OpenSSL::SSL::VERIFY_PEER
90
+ }
91
+
92
+ api_endpoint = "https://#{kubernetes_host}:#{kubernetes_port}/#{APPS_API_VERSION}"
93
+
94
+ Kubeclient::Client.new(
95
+ api_endpoint,
96
+ 'v1',
97
+ auth_options: auth_options,
98
+ ssl_options: ssl_options
99
+ )
100
+ end
101
+
102
+ def build_kubeconfig_client
103
+ kubeconfig_path = config.respond_to?(:kubernetes_kubeconfig) ? config.kubernetes_kubeconfig : nil
104
+ kubeconfig_path ||= ENV['KUBECONFIG'] || File.join(Dir.home, '.kube', 'config')
105
+
106
+ kubeconfig = Kubeclient::Config.read(kubeconfig_path)
107
+ context = kubeconfig.context(kubernetes_context)
108
+
109
+ api_endpoint = "#{context.api_endpoint}/#{APPS_API_VERSION}"
110
+
111
+ Kubeclient::Client.new(
112
+ api_endpoint,
113
+ 'v1',
114
+ ssl_options: context.ssl_options,
115
+ auth_options: context.auth_options
116
+ )
117
+ end
118
+
119
+ def in_cluster?
120
+ # Check if running inside a Kubernetes pod by looking for the service account token
121
+ File.exist?('/var/run/secrets/kubernetes.io/serviceaccount/token')
122
+ end
123
+
124
+ def kubernetes_host
125
+ ENV['KUBERNETES_SERVICE_HOST'] || 'kubernetes.default.svc'
126
+ end
127
+
128
+ def kubernetes_port
129
+ ENV['KUBERNETES_SERVICE_PORT'] || '443'
130
+ end
131
+
132
+ def deployment_name
133
+ if config.respond_to?(:kubernetes_deployment)
134
+ config.kubernetes_deployment
135
+ else
136
+ ENV.fetch('K8S_DEPLOYMENT', nil)
137
+ end
138
+ end
139
+
140
+ def namespace
141
+ ns = if config.respond_to?(:kubernetes_namespace)
142
+ config.kubernetes_namespace
143
+ else
144
+ ENV.fetch('K8S_NAMESPACE', nil)
145
+ end
146
+ ns || 'default'
147
+ end
148
+
149
+ def kubernetes_context
150
+ if config.respond_to?(:kubernetes_context)
151
+ config.kubernetes_context
152
+ else
153
+ ENV.fetch('K8S_CONTEXT', nil)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'adapters/base'
4
+ require_relative 'adapters/heroku'
5
+ require_relative 'adapters/kubernetes'
6
+
7
+ module SolidQueueHerokuAutoscaler
8
+ # Adapters module provides the plugin architecture for different platforms.
9
+ #
10
+ # Built-in adapters:
11
+ # - Adapters::Heroku (default)
12
+ # - Adapters::Kubernetes
13
+ #
14
+ # See Adapters::Base for creating custom adapters.
15
+ module Adapters
16
+ class << self
17
+ # Internal registry of adapter classes keyed by symbolic name.
18
+ #
19
+ # @return [Hash<Symbol, Class>]
20
+ def registry
21
+ @registry ||= {
22
+ heroku: Heroku,
23
+ kubernetes: Kubernetes,
24
+ k8s: Kubernetes
25
+ }
26
+ end
27
+
28
+ # Returns all registered adapter classes.
29
+ #
30
+ # @return [Array<Class>]
31
+ def all
32
+ registry.values.uniq
33
+ end
34
+
35
+ # Register a new adapter class.
36
+ #
37
+ # @param name [Symbol, String] symbolic name (e.g., :aws_ecs)
38
+ # @param klass [Class] adapter class (subclass of Adapters::Base)
39
+ # @return [void]
40
+ def register(name, klass)
41
+ registry[name.to_sym] = klass
42
+ end
43
+
44
+ # Find an adapter by symbolic name or by class short name.
45
+ #
46
+ # @param name [Symbol, String] adapter name (e.g., :heroku, :aws_ecs, 'Heroku')
47
+ # @return [Class, nil] adapter class or nil if not found
48
+ def find(name)
49
+ symbol = name.to_sym
50
+ return registry[symbol] if registry.key?(symbol)
51
+
52
+ name_str = name.to_s.downcase
53
+ all.find { |adapter| adapter.name.split('::').last.downcase == name_str }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module SolidQueueHerokuAutoscaler
6
+ class AdvisoryLock
7
+ attr_reader :lock_key, :timeout
8
+
9
+ def initialize(lock_key: nil, timeout: nil, config: nil)
10
+ @config = config || SolidQueueHerokuAutoscaler.config
11
+ @lock_key = lock_key || @config.lock_key
12
+ @timeout = timeout || @config.lock_timeout_seconds
13
+ @lock_acquired = false
14
+ end
15
+
16
+ def with_lock
17
+ acquire!
18
+ yield
19
+ ensure
20
+ release
21
+ end
22
+
23
+ def try_lock
24
+ return false if @lock_acquired
25
+
26
+ result = connection.select_value(
27
+ "SELECT pg_try_advisory_lock(#{lock_id})"
28
+ )
29
+ @lock_acquired = [true, 't'].include?(result)
30
+ @lock_acquired
31
+ end
32
+
33
+ def acquire!
34
+ return true if @lock_acquired
35
+
36
+ result = connection.select_value(
37
+ "SELECT pg_try_advisory_lock(#{lock_id})"
38
+ )
39
+ @lock_acquired = [true, 't'].include?(result)
40
+
41
+ raise LockError, "Could not acquire advisory lock '#{lock_key}' (id: #{lock_id})" unless @lock_acquired
42
+
43
+ true
44
+ end
45
+
46
+ def release
47
+ return false unless @lock_acquired
48
+
49
+ connection.execute("SELECT pg_advisory_unlock(#{lock_id})")
50
+ @lock_acquired = false
51
+ true
52
+ end
53
+
54
+ def locked?
55
+ @lock_acquired
56
+ end
57
+
58
+ private
59
+
60
+ def connection
61
+ @config.connection
62
+ end
63
+
64
+ def lock_id
65
+ @lock_id ||= begin
66
+ hash = Zlib.crc32(lock_key.to_s)
67
+ hash & 0x7FFFFFFF
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueHerokuAutoscaler
4
+ class AutoscaleJob < ActiveJob::Base
5
+ queue_as :autoscaler
6
+ discard_on ConfigurationError
7
+
8
+ # Scale a specific worker type, or all workers if :all is passed
9
+ # @param worker_name [Symbol] The worker type to scale (:default, :critical_worker, etc.)
10
+ # Pass :all to scale all registered workers
11
+ def perform(worker_name = :default)
12
+ if worker_name == :all
13
+ perform_scale_all
14
+ else
15
+ perform_scale_one(worker_name)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def perform_scale_one(worker_name)
22
+ result = SolidQueueHerokuAutoscaler.scale!(worker_name)
23
+
24
+ if result.success?
25
+ log_success(result, worker_name)
26
+ else
27
+ log_failure(result, worker_name)
28
+ raise result.error if result.error
29
+ end
30
+
31
+ result
32
+ end
33
+
34
+ def perform_scale_all
35
+ results = SolidQueueHerokuAutoscaler.scale_all!
36
+
37
+ results.each do |worker_name, result|
38
+ if result.success?
39
+ log_success(result, worker_name)
40
+ else
41
+ log_failure(result, worker_name)
42
+ end
43
+ end
44
+
45
+ # Raise the first error encountered, if any
46
+ failed_result = results.values.find { |r| !r.success? && r.error }
47
+ raise failed_result.error if failed_result
48
+
49
+ results
50
+ end
51
+
52
+ def log_success(result, worker_name)
53
+ worker_label = worker_name == :default ? '' : "[#{worker_name}] "
54
+ if result.scaled?
55
+ Rails.logger.info(
56
+ "[AutoscaleJob] #{worker_label}Scaled workers: #{result.decision.from} -> #{result.decision.to} " \
57
+ "(#{result.decision.reason})"
58
+ )
59
+ elsif result.skipped?
60
+ Rails.logger.debug("[AutoscaleJob] #{worker_label}Skipped: #{result.skipped_reason}")
61
+ else
62
+ Rails.logger.debug("[AutoscaleJob] #{worker_label}No change: #{result.decision&.reason}")
63
+ end
64
+ end
65
+
66
+ def log_failure(result, worker_name)
67
+ worker_label = worker_name == :default ? '' : "[#{worker_name}] "
68
+ Rails.logger.error("[AutoscaleJob] #{worker_label}Failed: #{result.error&.message}")
69
+ end
70
+ end
71
+ end