├── .gitignore ├── spec ├── spec_helper.rb └── heroku │ └── autoscale_spec.rb ├── README.md ├── Rakefile ├── lib └── heroku │ └── autoscale.rb └── heroku-autoscale.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | pkg/ 3 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "rack/test" 3 | require "rspec" 4 | 5 | $:.unshift "lib" 6 | 7 | Rspec.configure do |config| 8 | config.color_enabled = true 9 | config.mock_with :rr 10 | end 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Heroku::Autoscale 2 | 3 | WARNING: This gem is a proof of concept and should not be used in production 4 | applications. 5 | 6 | ## Installation 7 | 8 | # Gemfile 9 | gem 'heroku-autoscale' 10 | 11 | ## Usage (Rails 2.x) 12 | 13 | # config/environment.rb 14 | config.middleware.use Heroku::Autoscale, 15 | :username => ENV["HEROKU_USERNAME"], 16 | :password => ENV["HEROKU_PASSWORD"], 17 | :app_name => ENV["HEROKU_APP_NAME"], 18 | :min_dynos => 2, 19 | :max_dynos => 5, 20 | :queue_wait_low => 100, # milliseconds 21 | :queue_wait_high => 5000, # milliseconds 22 | :min_frequency => 10 # seconds 23 | 24 | ## Usage (Rails 3 / Rack) 25 | 26 | # config.ru 27 | use Heroku::Autoscale, 28 | :username => ENV["HEROKU_USERNAME"], 29 | :password => ENV["HEROKU_PASSWORD"], 30 | :app_name => ENV["HEROKU_APP_NAME"], 31 | :min_dynos => 2, 32 | :max_dynos => 5, 33 | :queue_wait_low => 100, # milliseconds 34 | :queue_wait_high => 5000, # milliseconds 35 | :min_frequency => 10 # seconds -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake" 2 | require "rspec" 3 | require "rspec/core/rake_task" 4 | 5 | $:.unshift File.expand_path("../lib", __FILE__) 6 | require "heroku/autoscale" 7 | 8 | task :default => :spec 9 | 10 | desc "Run all specs" 11 | Rspec::Core::RakeTask.new(:spec) do |t| 12 | t.pattern = 'spec/**/*_spec.rb' 13 | end 14 | 15 | desc "Generate RCov code coverage report" 16 | task :rcov => "rcov:build" do 17 | %x{ open coverage/index.html } 18 | end 19 | 20 | Rspec::Core::RakeTask.new("rcov:build") do |t| 21 | t.pattern = 'spec/**/*_spec.rb' 22 | t.rcov = true 23 | t.rcov_opts = [ "--exclude", Gem.default_dir , "--exclude", "spec" ] 24 | end 25 | 26 | ###################################################### 27 | 28 | begin 29 | require 'jeweler' 30 | Jeweler::Tasks.new do |s| 31 | s.name = "heroku-autoscale" 32 | s.version = Heroku::Autoscale::VERSION 33 | 34 | s.summary = "Autoscale your Heroku dynos" 35 | s.description = s.summary 36 | s.author = "David Dollar" 37 | s.email = "ddollar@gmail.com" 38 | s.homepage = "http://github.com/ddollar/heroku-autoscale" 39 | 40 | s.platform = Gem::Platform::RUBY 41 | s.has_rdoc = false 42 | 43 | s.files = %w(Rakefile README.md) + Dir["{bin,export,lib,spec}/**/*"] 44 | s.require_path = "lib" 45 | 46 | s.add_development_dependency 'rack-test', '~> 0.5.4' 47 | s.add_development_dependency 'rake', '~> 0.8.7' 48 | s.add_development_dependency 'rcov', '~> 0.9.8' 49 | s.add_development_dependency 'rr', '~> 0.10.11' 50 | s.add_development_dependency 'rspec', '~> 2.0.0' 51 | 52 | s.add_dependency 'eventmachine' 53 | s.add_dependency 'heroku', '~> 1.9' 54 | s.add_dependency 'rack', '~> 1.0' 55 | end 56 | Jeweler::GemcutterTasks.new 57 | rescue LoadError 58 | puts "Jeweler not available. Install it with: sudo gem install jeweler" 59 | end 60 | -------------------------------------------------------------------------------- /lib/heroku/autoscale.rb: -------------------------------------------------------------------------------- 1 | require "eventmachine" 2 | require "heroku" 3 | require "rack" 4 | 5 | module Heroku 6 | class Autoscale 7 | 8 | VERSION = "0.2.2" 9 | 10 | attr_reader :app, :options, :last_scaled 11 | 12 | def initialize(app, options={}) 13 | @app = app 14 | @options = default_options.merge(options) 15 | @last_scaled = Time.now - 60 16 | check_options! 17 | end 18 | 19 | def call(env) 20 | if options[:defer] 21 | EventMachine.defer { autoscale(env) } 22 | else 23 | autoscale(env) 24 | end 25 | 26 | app.call(env) 27 | end 28 | 29 | private ###################################################################### 30 | 31 | def autoscale(env) 32 | # dont do anything if we scaled too frequently ago 33 | return if (Time.now - last_scaled) < options[:min_frequency] 34 | 35 | original_dynos = dynos = current_dynos 36 | wait = queue_wait(env) 37 | 38 | dynos -= 1 if wait <= options[:queue_wait_low] 39 | dynos += 1 if wait >= options[:queue_wait_high] 40 | 41 | dynos = options[:min_dynos] if dynos < options[:min_dynos] 42 | dynos = options[:max_dynos] if dynos > options[:max_dynos] 43 | dynos = 1 if dynos < 1 44 | 45 | set_dynos(dynos) if dynos != original_dynos 46 | end 47 | 48 | def check_options! 49 | errors = [] 50 | errors << "Must supply :username to Heroku::Autoscale" unless options[:username] 51 | errors << "Must supply :password to Heroku::Autoscale" unless options[:password] 52 | errors << "Must supply :app_name to Heroku::Autoscale" unless options[:app_name] 53 | raise errors.join(" / ") unless errors.empty? 54 | end 55 | 56 | def current_dynos 57 | heroku.info(options[:app_name])[:dynos].to_i 58 | end 59 | 60 | def default_options 61 | { 62 | :defer => true, 63 | :min_dynos => 1, 64 | :max_dynos => 1, 65 | :queue_wait_high => 5000, # milliseconds 66 | :queue_wait_low => 0, # milliseconds 67 | :min_frequency => 10 # seconds 68 | } 69 | end 70 | 71 | def heroku 72 | @heroku ||= Heroku::Client.new(options[:username], options[:password]) 73 | end 74 | 75 | def queue_wait(env) 76 | env["HTTP_X_HEROKU_QUEUE_WAIT_TIME"].to_i 77 | end 78 | 79 | def set_dynos(count) 80 | heroku.set_dynos(options[:app_name], count) 81 | @last_scaled = Time.now 82 | end 83 | 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /heroku-autoscale.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command 4 | # -*- encoding: utf-8 -*- 5 | 6 | Gem::Specification.new do |s| 7 | s.name = %q{heroku-autoscale} 8 | s.version = "0.2.2" 9 | 10 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 11 | s.authors = ["David Dollar"] 12 | s.date = %q{2010-07-09} 13 | s.description = %q{Autoscale your Heroku dynos} 14 | s.email = %q{ddollar@gmail.com} 15 | s.extra_rdoc_files = [ 16 | "README.md" 17 | ] 18 | s.files = [ 19 | "README.md", 20 | "Rakefile", 21 | "lib/heroku/autoscale.rb", 22 | "spec/heroku/autoscale_spec.rb", 23 | "spec/spec_helper.rb" 24 | ] 25 | s.has_rdoc = false 26 | s.homepage = %q{http://github.com/ddollar/heroku-autoscale} 27 | s.rdoc_options = ["--charset=UTF-8"] 28 | s.require_paths = ["lib"] 29 | s.rubygems_version = %q{1.3.7} 30 | s.summary = %q{Autoscale your Heroku dynos} 31 | s.test_files = [ 32 | "spec/heroku/autoscale_spec.rb", 33 | "spec/spec_helper.rb" 34 | ] 35 | 36 | if s.respond_to? :specification_version then 37 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 38 | s.specification_version = 3 39 | 40 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 41 | s.add_development_dependency(%q, ["~> 0.5.4"]) 42 | s.add_development_dependency(%q, ["~> 0.8.7"]) 43 | s.add_development_dependency(%q, ["~> 0.9.8"]) 44 | s.add_development_dependency(%q, ["~> 0.10.11"]) 45 | s.add_development_dependency(%q, ["~> 2.0.0"]) 46 | s.add_runtime_dependency(%q, [">= 0"]) 47 | s.add_runtime_dependency(%q, [">= 1.9"]) 48 | s.add_runtime_dependency(%q, ["~> 1.0"]) 49 | else 50 | s.add_dependency(%q, ["~> 0.5.4"]) 51 | s.add_dependency(%q, ["~> 0.8.7"]) 52 | s.add_dependency(%q, ["~> 0.9.8"]) 53 | s.add_dependency(%q, ["~> 0.10.11"]) 54 | s.add_dependency(%q, ["~> 2.0.0"]) 55 | s.add_dependency(%q, [">= 0"]) 56 | s.add_dependency(%q, [">= 1.9"]) 57 | s.add_dependency(%q, ["~> 1.0"]) 58 | end 59 | else 60 | s.add_dependency(%q, ["~> 0.5.4"]) 61 | s.add_dependency(%q, ["~> 0.8.7"]) 62 | s.add_dependency(%q, ["~> 0.9.8"]) 63 | s.add_dependency(%q, ["~> 0.10.11"]) 64 | s.add_dependency(%q, ["~> 2.0.0"]) 65 | s.add_dependency(%q, [">= 0"]) 66 | s.add_dependency(%q, [">= 1.9"]) 67 | s.add_dependency(%q, ["~> 1.0"]) 68 | end 69 | end 70 | 71 | -------------------------------------------------------------------------------- /spec/heroku/autoscale_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "heroku/autoscale" 3 | 4 | describe Heroku::Autoscale do 5 | 6 | include Rack::Test::Methods 7 | 8 | def noop 9 | lambda {} 10 | end 11 | 12 | describe "option validation" do 13 | it "requires username" do 14 | lambda { Heroku::Autoscale.new(noop) }.should raise_error(/Must supply :username/) 15 | end 16 | 17 | it "requires password" do 18 | lambda { Heroku::Autoscale.new(noop) }.should raise_error(/Must supply :password/) 19 | end 20 | 21 | it "requires app_name" do 22 | lambda { Heroku::Autoscale.new(noop) }.should raise_error(/Must supply :app_name/) 23 | end 24 | end 25 | 26 | describe "with valid options" do 27 | let(:app) do 28 | Heroku::Autoscale.new noop, 29 | :defer => false, 30 | :username => "test_username", 31 | :password => "test_password", 32 | :app_name => "test_app_name", 33 | :min_dynos => 1, 34 | :max_dynos => 10, 35 | :queue_wait_low => 10, 36 | :queue_wait_high => 100, 37 | :min_frequency => 10 38 | end 39 | 40 | it "scales up" do 41 | heroku = mock(Heroku::Client) 42 | heroku.info("test_app_name") { { :dynos => 1 } } 43 | heroku.set_dynos("test_app_name", 2) 44 | 45 | mock(app).heroku.times(any_times) { heroku } 46 | app.call({ "HTTP_X_HEROKU_QUEUE_WAIT_TIME" => 101 }) 47 | end 48 | 49 | it "scales down" do 50 | heroku = mock(Heroku::Client) 51 | heroku.info("test_app_name") { { :dynos => 3 } } 52 | heroku.set_dynos("test_app_name", 2) 53 | 54 | mock(app).heroku.times(any_times) { heroku } 55 | app.call({ "HTTP_X_HEROKU_QUEUE_WAIT_TIME" => 9 }) 56 | end 57 | 58 | it "wont go below one dyno" do 59 | heroku = mock(Heroku::Client) 60 | heroku.info("test_app_name") { { :dynos => 1 } } 61 | heroku.set_dynos.times(0) 62 | 63 | mock(app).heroku.times(any_times) { heroku } 64 | app.call({ "HTTP_X_HEROKU_QUEUE_WAIT_TIME" => 9 }) 65 | end 66 | 67 | it "respects max dynos" do 68 | heroku = mock(Heroku::Client) 69 | heroku.info("test_app_name") { { :dynos => 10 } } 70 | heroku.set_dynos.times(0) 71 | 72 | mock(app).heroku.times(any_times) { heroku } 73 | app.call({ "HTTP_X_HEROKU_QUEUE_WAIT_TIME" => 101 }) 74 | end 75 | 76 | it "respects min dynos" do 77 | app.options[:min_dynos] = 2 78 | heroku = mock(Heroku::Client) 79 | heroku.info("test_app_name") { { :dynos => 2 } } 80 | heroku.set_dynos.times(0) 81 | 82 | mock(app).heroku.times(any_times) { heroku } 83 | app.call({ "HTTP_X_HEROKU_QUEUE_WAIT_TIME" => 9 }) 84 | end 85 | 86 | it "doesnt flap" do 87 | heroku = mock(Heroku::Client) 88 | heroku.info("test_app_name").once { { :dynos => 5 } } 89 | heroku.set_dynos.with_any_args.once 90 | 91 | mock(app).heroku.times(any_times) { heroku } 92 | app.call({ "HTTP_X_HEROKU_QUEUE_WAIT_TIME" => 9 }) 93 | app.call({ "HTTP_X_HEROKU_QUEUE_WAIT_TIME" => 9 }) 94 | end 95 | end 96 | 97 | end 98 | --------------------------------------------------------------------------------