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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +128 -0
- data/LICENSE.txt +21 -0
- data/README.md +474 -0
- data/lib/generators/solid_queue_heroku_autoscaler/install_generator.rb +21 -0
- data/lib/generators/solid_queue_heroku_autoscaler/migration_generator.rb +29 -0
- data/lib/generators/solid_queue_heroku_autoscaler/templates/README +41 -0
- data/lib/generators/solid_queue_heroku_autoscaler/templates/create_solid_queue_autoscaler_state.rb.erb +15 -0
- data/lib/generators/solid_queue_heroku_autoscaler/templates/initializer.rb +52 -0
- data/lib/solid_queue_heroku_autoscaler/adapters/base.rb +102 -0
- data/lib/solid_queue_heroku_autoscaler/adapters/heroku.rb +93 -0
- data/lib/solid_queue_heroku_autoscaler/adapters/kubernetes.rb +158 -0
- data/lib/solid_queue_heroku_autoscaler/adapters.rb +57 -0
- data/lib/solid_queue_heroku_autoscaler/advisory_lock.rb +71 -0
- data/lib/solid_queue_heroku_autoscaler/autoscale_job.rb +71 -0
- data/lib/solid_queue_heroku_autoscaler/configuration.rb +217 -0
- data/lib/solid_queue_heroku_autoscaler/cooldown_tracker.rb +153 -0
- data/lib/solid_queue_heroku_autoscaler/decision_engine.rb +228 -0
- data/lib/solid_queue_heroku_autoscaler/errors.rb +44 -0
- data/lib/solid_queue_heroku_autoscaler/metrics.rb +172 -0
- data/lib/solid_queue_heroku_autoscaler/railtie.rb +149 -0
- data/lib/solid_queue_heroku_autoscaler/scaler.rb +227 -0
- data/lib/solid_queue_heroku_autoscaler/version.rb +5 -0
- data/lib/solid_queue_heroku_autoscaler.rb +106 -0
- metadata +169 -0
|
@@ -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
|