Skip to content

Instantly share code, notes, and snippets.

@MakarovCode
Last active March 14, 2025 09:22
Show Gist options
  • Save MakarovCode/2f9493683d8256f31523c80ff6e8e0f0 to your computer and use it in GitHub Desktop.
Save MakarovCode/2f9493683d8256f31523c80ff6e8e0f0 to your computer and use it in GitHub Desktop.
Heroku dynos autoscaler with redis and sidekiq
# app/controllers/application_controller.rb
# Base controller for a Rails app integrated with Heroku Autoscaler.
# Tracks web response times and stores them in Redis for autoscaling decisions.
# Usage: Works with Heroku::Autoscaler to scale the 'web' dyno based on average response time.
# Requirements: 'redis' gem, REDIS_URL env variable set (e.g., via Heroku Redis add-on).
require 'json'
class ApplicationController < ActionController::Base
# Hook to measure response time for every action in the app
around_action :track_web_load
# Example authentication method to set the current user based on an auth token
# Not directly related to autoscaling, but included as a typical controller feature
def auth_user
@current_user = User.find_by_token params[:auth_token]
end
private
# Tracks the time taken to process each request and updates a rolling list of response times
# Stored in Redis as "web:last_response_times" for use by Heroku::Autoscaler
def track_web_load
# Record the start time of the request
start_time = Time.now
# Execute the action (controller logic)
yield
# Calculate duration in milliseconds (multiply by 1000 for ms from seconds)
duration = (Time.now - start_time) * 1000.0
# Fetch the current list of recent response times from Redis
last_responses_times = redis.get("web:last_responses_times")
if last_responses_times.nil?
# Initialize an empty array if no data exists yet
last_responses_times = []
else
# Parse the JSON string from Redis into a Ruby array
last_responses_times = JSON.parse(last_responses_times)
end
# Calculate the average response time (avoid division by zero)
avr_response_time = last_responses_times.sum / (last_responses_times.size.zero? ? 1 : last_responses_times.size)
# Only track significant response times (e.g., >= 100ms) to filter out noise
last_responses_times.push(duration) if duration >= 100
# Keep the list at 10 elements max by removing the oldest (first) entry
last_responses_times.shift if last_responses_times.size > 10
# Debug output to logs for monitoring (visible in `heroku logs`)
puts "=========> LAST DURATION <=============="
puts "=========> #{duration}ms <=============="
puts "=========> LAST DURATION <=============="
puts "#{last_responses_times.inspect}" # Show the current list
puts "=========> AVG DURATION <=============="
puts "=========> #{avr_response_time}ms <=============="
puts "=========> AVG DURATION <=============="
# Store the updated list in Redis atomically
redis.multi do |multi|
# Serialize the array to JSON before saving (Redis stores strings)
multi.set("web:last_responses_times", last_responses_times.to_json)
end
end
# Lazy-loaded Redis client instance
# Reuses the same connection across requests for efficiency
def redis
@redis ||= Redis.new(url: ENV['REDIS_URL'] || 'redis://localhost:6379/0')
end
end
# Heroku Autoscaler Module
# Dynamically scales Heroku dynos for Rails with Sidekiq, factoring in concurrency and DB limits.
module Heroku
require 'sidekiq' # Sidekiq for queue monitoring
require 'platform-api' # Heroku API for dyno management
require 'redis' # Redis for web response times
class Autoscaler
attr_accessor :heroku # Heroku API client accessor
# Web response time thresholds (ms)
MIN_WEB_QUEUE_TIME = 1000 # Scale down if below this
MAX_WEB_QUEUE_TIME = 1000 # Scale up if above this
# Config from environment variables with defaults
HEROKU_APP_NAME = ENV["HEROKU_APP_NAME"] # App name
HEROKU_API_TOKEN = ENV["HEROKU_API_TOKEN"] # API token
MAX_DB_CONNECTIONS = ENV["MAX_DB_CONNECTIONS"]&.to_i || 40 # Max DB connections (default 40)
CONNECTIONS_PER_DYNO = ENV["CONNECTIONS_PER_DYNO"]&.to_i || 2 # Connections per dyno (default 2)
SIDEKIQ_CONCURRENCY = ENV["SIDEKIQ_CONCURRENCY"]&.to_i || 5 # Sidekiq threads per dyno (default 5)
# Process types with queue names and dyno limits
QUEUES = {
'web' => { queue: :web, min: 1, max: 3 }, # Web process (Rails)
'sidekiq_default' => { queue: 'default', min: 1, max: 3 }, # Sidekiq queues
'sidekiq_amaterasu' => { queue: 'amaterasu', min: 0, max: 2 },
'sidekiq_rag' => { queue: 'rag', min: 0, max: 5 },
'sidekiq_automations' => { queue: 'automations', min: 0, max: 1 },
'sidekiq_twilio' => { queue: 'twilio', min: 1, max: 2 },
'sidekiq_scrapping' => { queue: 'scrapping', min: 0, max: 1 }
}
# Initialize Heroku client
def initialize
@heroku = PlatformAPI.connect(HEROKU_API_TOKEN)
end
# Fetch current dyno count for a process
def current_dyno_count(process_type)
formation = @heroku.formation.info(HEROKU_APP_NAME, process_type)
formation['quantity'] || 0 # Default to 0 if no formation
rescue PlatformAPI::NotFound
0 # Return 0 if process doesn’t exist
end
# Update dyno count via Heroku API
def update_dyno_count(process_type, quantity)
@heroku.formation.update(HEROKU_APP_NAME, process_type, { quantity: quantity })
puts "Updated #{process_type} to #{quantity} dynos" # Log update
rescue StandardError => e
puts "Error updating #{process_type}: #{e.message}" # Log errors
end
# Total DB connections across all dynos
def total_connections
QUEUES.sum { |process_type, _| current_dyno_count(process_type) * CONNECTIONS_PER_DYNO }
end
# Total dynos across all processes
def overall_dyno_count
QUEUES.sum { |process_type, _| current_dyno_count(process_type) }
end
# Count busy jobs in a Sidekiq queue
def busy_jobs_for_queue(queue_name)
busy_count = 0
Sidekiq::Workers.new.each do |_, _, work|
busy_count += 1 if work['queue'] == queue_name # Count matching jobs
end
busy_count
end
# Average web response time from Redis
def web_last_response_time
last_responses_times = redis.get("web:last_responses_times") # Fetch from Redis
if last_responses_times.nil?
last_responses_times = [] # Empty if no data
else
last_responses_times = JSON.parse(last_responses_times) # Parse JSON
end
avg = last_responses_times.sum / (last_responses_times.size.zero? ? 1 : last_responses_times.size) # Avoid div by 0
puts "Web avg response time: #{avg.round(2)}ms" # Log average
avg
end
# Main autoscaling logic
def autoscale
QUEUES.each do |process_type, config|
queue_name = config[:queue] # Queue name or :web
min_dynos = config[:min] # Min dynos
max_dynos = config[:max] # Max dynos
# Current dyno count
current_dynos = current_dyno_count(process_type)
puts "#{process_type} (queue: #{queue_name}) has #{current_dynos} dynos"
# Queue size (enqueued jobs) - Note: Errors for :web
queue = Sidekiq::Queue.new(queue_name)
enqueued_size = queue.size
puts "#{queue_name} has #{enqueued_size} enqueued jobs"
# Busy jobs
busy_size = busy_jobs_for_queue(queue_name)
puts "#{queue_name} has #{busy_size} busy jobs"
# Total workload
total_workload = enqueued_size + busy_size
puts "#{queue_name} total workload: #{total_workload}"
desired_dynos = min_dynos # Default to min
if queue_name == :web
# Web scaling based on response time
avg_response_time = web_last_response_time
desired_dynos = if avg_response_time > MAX_WEB_QUEUE_TIME
[current_dynos + 1, max_dynos].min # Scale up
elsif avg_response_time <= MIN_WEB_QUEUE_TIME && current_dynos > min_dynos
[current_dynos - 1, min_dynos].max # Scale down
else
current_dynos # No change
end
else
# Sidekiq scaling with concurrency
desired_dynos = if total_workload > current_dynos * SIDEKIQ_CONCURRENCY
[(total_workload.to_f / SIDEKIQ_CONCURRENCY).ceil, max_dynos].min # Scale up
elsif total_workload <= current_dynos * SIDEKIQ_CONCURRENCY && total_workload >= min_dynos
[(total_workload.to_f / SIDEKIQ_CONCURRENCY).ceil, max_dynos].min # Adjust to workload
else
min_dynos # Respect min
end
end
# DB connection check
total_conn = total_connections
overall_dynos = overall_dyno_count
process_used_connections = current_dynos * CONNECTIONS_PER_DYNO
new_conn = desired_dynos * CONNECTIONS_PER_DYNO # Desired connections
requested_connections = new_conn - process_used_connections # Additional connections
if requested_connections + total_conn > MAX_DB_CONNECTIONS
available_connections = MAX_DB_CONNECTIONS - total_conn
# Corrected line: Returns an integer, not an array, capping additional dynos
max_additional_dynos = [[0, (available_connections / CONNECTIONS_PER_DYNO).floor].min, [max_dynos - current_dynos, 0].max].min
if max_additional_dynos > 0
adjusted_new_conn = process_used_connections + (max_additional_dynos * CONNECTIONS_PER_DYNO)
puts "Warning: Requested scale (#{new_conn}) for #{process_type} would exceed DB connection limit " \
"(#{requested_connections + total_conn}/#{MAX_DB_CONNECTIONS}). " \
"Scaling to maximum possible (#{adjusted_new_conn}) instead, considering overall dynos (#{overall_dynos})."
desired_dynos = (adjusted_new_conn / CONNECTIONS_PER_DYNO).floor # Adjust to fit
else
puts "Cannot scale #{process_type}: would exceed DB connection limit " \
"(#{requested_connections + total_conn}/#{MAX_DB_CONNECTIONS}), " \
"considering overall dynos (#{overall_dynos})."
next # Skip scaling
end
end
# Apply new dyno count
if desired_dynos != current_dynos
update_dyno_count(process_type, desired_dynos)
else
puts "#{process_type} already at desired count (#{desired_dynos})"
end
end
end
# Redis client for web response times
def redis
@redis ||= Redis.new(url: ENV['REDIS_URL'] || 'redis://localhost:6379/0')
end
end
end
@MakarovCode
Copy link
Author

MakarovCode commented Mar 12, 2025

Description

The Heroku::Autoscaler and ApplicationController duo dynamically scales Rails apps on Heroku, adjusting Sidekiq queue dynos based on workload and the web dyno using Redis-stored response times (last 10 ≥100ms). It optimizes resources with min/max limits and DB connection caps, ensuring efficiency and responsiveness.

Tested with

redis (4.8.1)
sidekiq (7.2.4)
ruby (3.2.2)
rails (7.0.8.3)

Important

Be aware of ENV variables, queues names, min and max, web and default should be at min: 1

HEROKU_APP_NAME = ENV["HEROKU_APP_NAME"] 
HEROKU_API_TOKEN = ENV["HEROKU_API_TOKEN"] 
MAX_DB_CONNECTIONS = ENV["MAX_DB_CONNECTIONS"].to_i 
CONNECTIONS_PER_DYNO = ENV["CONNECTIONS_PER_DYNO"].to_i
QUEUES = {
  'web' => { queue: :web, min: 1, max: 3 },
  'sidekiq_default' => { queue: 'default', min: 1, max: 3 },
  'sidekiq_amaterasu' => { queue: 'amaterasu', min: 0, max: 2 },
  'sidekiq_rag' => { queue: 'rag', min: 0, max: 5 },
  'sidekiq_automations' => { queue: 'automations', min: 0, max: 1 },
  'sidekiq_twilio' => { queue: 'twilio', min: 1, max: 2 },
  'sidekiq_scrapping' => { queue: 'scrapping', min: 0, max: 1 }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment