heroku-vector 0.0.2

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,10 @@
1
+ # Example Upstart config to run heroku_vector on Linux
2
+ # eg: /etc/init/heroku-vector.conf
3
+
4
+ respawn
5
+ respawn limit 15 5
6
+
7
+ start on runlevel [2345]
8
+ stop on runlevel [06]
9
+
10
+ exec /usr/local/bin/heroku_vector -c /path/to/your/config.rb -l /var/log/heroku_vector.log
@@ -0,0 +1,38 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'heroku_vector/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "heroku-vector"
8
+ spec.version = HerokuVector::VERSION
9
+ spec.authors = ["Winfield Peterson"]
10
+ spec.email = ["winfield.peterson@gmail.com"]
11
+ spec.summary = %q{Sampling Auto-scaler for Heroku dynos}
12
+ spec.description = %q{Linearly scale Heroku dyno counts based on sampled metrics}
13
+ spec.homepage = "https://github.com/wpeterson/heroku-vector"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "activeresource"
22
+ spec.add_dependency "heroku-api"
23
+ spec.add_dependency "newrelic_api"
24
+ spec.add_dependency "redis"
25
+ spec.add_dependency "redis-namespace"
26
+ spec.add_dependency "sidekiq"
27
+ spec.add_dependency "dotenv"
28
+ spec.add_dependency "eventmachine"
29
+
30
+ spec.add_development_dependency "minitest"
31
+ spec.add_development_dependency "mocha"
32
+ spec.add_development_dependency "bundler", "~> 1.6"
33
+ spec.add_development_dependency "rake"
34
+ spec.add_development_dependency "timecop"
35
+ spec.add_development_dependency "webmock"
36
+ spec.add_development_dependency "vcr"
37
+ spec.add_development_dependency "pry"
38
+ end
@@ -0,0 +1,133 @@
1
+ module HerokuVector
2
+ module Engine
3
+ end
4
+ module Source
5
+ end
6
+ end
7
+
8
+ require 'logger'
9
+
10
+ require 'heroku_vector/helper'
11
+
12
+ require 'heroku_vector/dyno_scaler'
13
+ require 'heroku_vector/process_manager'
14
+ require 'heroku_vector/sampler'
15
+ require 'heroku_vector/worker'
16
+ require 'heroku_vector/version'
17
+ require 'heroku_vector/engine/heroku'
18
+ require 'heroku_vector/source/new_relic'
19
+ require 'heroku_vector/source/sidekiq'
20
+
21
+ module HerokuVector
22
+ class << self
23
+
24
+ def default_logger
25
+ return options[:logger] if options[:logger]
26
+
27
+ logger = Logger.new(STDOUT)
28
+ logger.level = options[:log_level]
29
+ logger.formatter = proc do |severity, time, program_name, message|
30
+ "#{time.utc.iso8601(3)} #{severity}: #{message}\n"
31
+ end
32
+
33
+ logger
34
+ end
35
+
36
+ def options
37
+ @options ||= DEFAULTS.dup
38
+ end
39
+
40
+ def options=(opts)
41
+ @options = opts
42
+ end
43
+
44
+ def configure
45
+ yield self if block_given?
46
+ end
47
+
48
+ def logger
49
+ @logger ||= default_logger
50
+ end
51
+
52
+ def logger=(log)
53
+ @logger = log
54
+ end
55
+
56
+ def engine
57
+ @engine ||= options[:engine]
58
+ end
59
+
60
+ def engine=(engine)
61
+ @engine = engine
62
+ end
63
+
64
+ def dyno_scalers
65
+ @dyno_scalers ||= {}
66
+ end
67
+
68
+ def dyno_scalers=(dyno_scalers)
69
+ @dyno_scalers = dyno_scalers
70
+ end
71
+
72
+ def add_dyno_scaler(name, options={})
73
+ dyno_scalers[name] = options
74
+ end
75
+
76
+ def min_scale_time_secs
77
+ @min_scale_time_secs ||= options[:min_scale_time_secs]
78
+ end
79
+
80
+ def min_scale_time_secs=(time_secs)
81
+ @min_scale_time_secs = time_secs
82
+ end
83
+
84
+ def heroku_app_name
85
+ @heroku_app_name ||= ENV['HEROKU_APP_NAME']
86
+ end
87
+
88
+ def heroku_app_name=(app_name)
89
+ @heroku_app_name = app_name
90
+ end
91
+
92
+ def newrelic_api_key
93
+ @newrelic_api_key ||= ENV['NEWRELIC_API_KEY']
94
+ end
95
+
96
+ def newrelic_api_key=(api_key)
97
+ @newrelic_api_key = api_key
98
+ end
99
+
100
+ def newrelic_account_id
101
+ @newrelic_account_id ||= ENV['NEWRELIC_ACCOUNT_ID']
102
+ end
103
+
104
+ def newrelic_account_id=(account_id)
105
+ @newrelic_account_id = account_id
106
+ end
107
+
108
+ def newrelic_app_id
109
+ @newrelic_app_id ||= ENV['NEWRELIC_APP_ID']
110
+ end
111
+
112
+ def newrelic_app_id=(app_id)
113
+ @newrelic_app_id = app_id
114
+ end
115
+
116
+ def sidekiq_redis_url
117
+ ENV['REDIS_URL'] || 'redis://127.0.0.1/0'
118
+ end
119
+
120
+ def sidekiq_redis_namespace
121
+ ENV['SIDEKIQ_REDIS_NAMESPACE']
122
+ end
123
+ end
124
+ end
125
+
126
+ module HerokuVector
127
+ DEFAULTS = {
128
+ :min_scale_time_secs => 5 * 60, # 5 mins
129
+ :log_level => Logger::INFO,
130
+ :engine => Engine::Heroku.new
131
+ }
132
+
133
+ end
@@ -0,0 +1,133 @@
1
+ module HerokuVector
2
+ class DynoScaler
3
+ include HerokuVector::Helper
4
+
5
+ MIN_SCALE_TIME_DELTA_SEC = 5 * 60 # 5 mins
6
+
7
+ attr_accessor :source, :last_scale_time
8
+ attr_reader :name,
9
+ :sampler, :period, :min_value, :max_value,
10
+ :min_dynos, :max_dynos,
11
+ :engine, :scale_up_by, :scale_down_by
12
+
13
+ def initialize(name, options={})
14
+ @name = name
15
+ @period = options[:period] || 60 # 1 min
16
+ sample_size = options[:sample_size] || Sampler.capacity_for_sample_period(@period)
17
+ @sampler = Sampler.new( sample_size )
18
+ @min_dynos = options[:min_dynos] || 2
19
+ @max_dynos = options[:max_dynos] || 10
20
+ @min_value = options[:min_value] || raise("DynoScaler: min_value required")
21
+ @max_value = options[:max_value] || raise("DynoScaler: max_value required")
22
+
23
+ @scale_up_by = options[:scale_up_by] || 1
24
+ @scale_down_by = options[:scale_down_by] || 1
25
+ @source = load_data_source(options)
26
+ @engine = options[:engine] || Engine::Heroku.new
27
+ end
28
+
29
+ def load_data_source(options)
30
+ source = options[:source]
31
+
32
+ if source.is_a?(Class)
33
+ @source = source.new(options)
34
+ else
35
+ begin
36
+ clazz = HerokuVector::Source.const_get(source)
37
+ @source = clazz.new(options)
38
+ rescue
39
+ raise "DynoScaler: Invalid source class '#{source}'"
40
+ end
41
+ end
42
+ end
43
+
44
+ def reset
45
+ sampler.clear
46
+ @last_scale_time = nil
47
+ end
48
+
49
+ def run
50
+ begin
51
+ collect_sample
52
+ return unless enough_samples?
53
+ return if scaling_too_soon?
54
+
55
+ evaluate_and_scale
56
+ rescue => e
57
+ logger.error "#{self.name} worker.run(): #{e}"
58
+ end
59
+ end
60
+
61
+ def evaluate_and_scale
62
+ num_dynos = current_size
63
+ total_min = min_value * num_dynos
64
+ total_max = max_value * num_dynos
65
+ value = current_value
66
+
67
+ logger.debug "#{self.name}: #{num_dynos} dynos - #{value} #{display_unit}"
68
+ if value < total_min
69
+ unless num_dynos <= min_dynos
70
+ logger.info "#{self.name}: #{num_dynos} dynos - #{value} #{display_unit} below #{total_min} - scaling down"
71
+ end
72
+ # Always scale down one at a time
73
+ new_amount = num_dynos - scale_down_by
74
+ scale_dynos(num_dynos, new_amount)
75
+ elsif value > total_max
76
+ unless num_dynos >= max_dynos
77
+ logger.info "#{self.name}: #{num_dynos} dynos - #{value} #{display_unit} above #{total_max} - scaling up"
78
+ end
79
+ # Scale up to N new dynos
80
+ new_amount = num_dynos + scale_up_by
81
+ scale_dynos(num_dynos, new_amount)
82
+ end
83
+ end
84
+
85
+ def current_value
86
+ sampler.mean
87
+ end
88
+
89
+ def collect_sample
90
+ sampler << source.sample
91
+ end
92
+
93
+ def current_size
94
+ engine.count_for_dyno_name(self.name)
95
+ end
96
+
97
+ def scale_dynos(current_amount, new_amount)
98
+ new_amount = normalize_dyno_increment(new_amount)
99
+ return if current_amount == new_amount
100
+
101
+ record_last_scale_event
102
+
103
+ engine.scale_dynos(self.name, new_amount)
104
+ end
105
+
106
+ def normalize_dyno_increment(amount)
107
+ [
108
+ [amount, max_dynos].min,
109
+ min_dynos
110
+ ].max
111
+ end
112
+
113
+ def record_last_scale_event
114
+ @last_scale_time = Time.now
115
+ end
116
+
117
+ def enough_samples?
118
+ sampler.full?
119
+ end
120
+
121
+ def scaling_too_soon?
122
+ return false unless last_scale_time
123
+ scale_delta = Time.now - last_scale_time
124
+
125
+ MIN_SCALE_TIME_DELTA_SEC >= scale_delta
126
+ end
127
+
128
+ def display_unit
129
+ source.unit rescue 'units'
130
+ end
131
+
132
+ end
133
+ end
@@ -0,0 +1,63 @@
1
+ require 'heroku-api'
2
+
3
+ module HerokuVector::Engine
4
+ class Heroku
5
+ include HerokuVector::Helper
6
+
7
+ CacheEntry = Struct.new(:data, :expires_at)
8
+
9
+ attr_accessor :app, :heroku
10
+
11
+ def initialize(options={})
12
+ options = { app: HerokuVector.heroku_app_name }.merge(options)
13
+
14
+ @app = options[:app]
15
+ @heroku = ::Heroku::API.new
16
+ end
17
+
18
+ def get_dynos_by_name_cached
19
+ if @dynos_by_name && @dynos_by_name.expires_at > Time.now
20
+ return @dynos_by_name.data
21
+ end
22
+
23
+ @dynos_by_name = begin
24
+ expires_at = Time.now + 60 # 1 min from now
25
+ CacheEntry.new(get_dynos_by_name, expires_at)
26
+ rescue
27
+ nil
28
+ end
29
+ @dynos_by_name.data
30
+ end
31
+
32
+ def get_dynos_by_name
33
+ dyno_types = get_dyno_types
34
+ dyno_types.index_by {|dyno| dyno["name"] }
35
+ end
36
+
37
+ def get_dyno_types
38
+ response = heroku.get_dyno_types(app)
39
+ assert_heroku_api_success(response)
40
+ response.body
41
+ end
42
+
43
+ def count_for_dyno_name(dyno_name)
44
+ dyno = get_dynos_by_name_cached[dyno_name]
45
+ return 0 unless dyno
46
+
47
+ dyno['quantity'] || 0
48
+ end
49
+
50
+ def scale_dynos(dyno_name, count)
51
+ response = heroku.post_ps_scale(app, dyno_name, count)
52
+ assert_heroku_api_success(response)
53
+ logger.info "Heroku.scale_dynos(#{dyno_name}, #{count})"
54
+
55
+ response.body
56
+ end
57
+
58
+ def assert_heroku_api_success(response)
59
+ raise 'Invalid Heroku API Response' unless response
60
+ raise "Error #{response.status} from Heroku API" unless response.status == 200
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,23 @@
1
+
2
+ module HerokuVector
3
+ module Helper
4
+ def self.included(target)
5
+ target.send(:include, InstanceMethods)
6
+ target.extend ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ end
11
+
12
+ module InstanceMethods
13
+ def logger
14
+ HerokuVector.logger
15
+ end
16
+
17
+ def round_to_one_decimal(value)
18
+ ((value * 10.0).floor.to_f / 10.0)
19
+ end
20
+
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,61 @@
1
+
2
+ module HerokuVector
3
+ class ProcessManager
4
+ include HerokuVector::Helper
5
+
6
+ attr_accessor :options
7
+
8
+ def initialize(options = {})
9
+ @options = options
10
+ end
11
+
12
+ def start
13
+ daemonize if options[:daemonize]
14
+ redirect_to_logfile if options[:logfile]
15
+ write_pid
16
+
17
+ worker = Worker.new(options)
18
+ worker.run
19
+ end
20
+
21
+ def daemonize
22
+ return unless options[:daemonize]
23
+
24
+ raise ArgumentError, "Daemonized mode requires a logfile" unless options[:logfile]
25
+ files_to_reopen = []
26
+ ObjectSpace.each_object(File) do |file|
27
+ files_to_reopen << file unless file.closed?
28
+ end
29
+
30
+ ::Process.daemon(true, true)
31
+
32
+ files_to_reopen.each do |file|
33
+ begin
34
+ file.reopen file.path, "a+"
35
+ file.sync = true
36
+ rescue ::Exception
37
+ end
38
+ end
39
+ end
40
+
41
+ def redirect_to_logfile
42
+ [$stdout, $stderr].each do |io|
43
+ File.open(options[:logfile], 'ab') do |f|
44
+ io.reopen(f)
45
+ end
46
+ io.sync = true
47
+ end
48
+ end
49
+
50
+ def write_pid
51
+ return unless path = options[:pidfile]
52
+
53
+ pidfile = File.expand_path(path)
54
+ logger.info "Writing pidfile #{pidfile}"
55
+ File.open(pidfile, 'w') do |f|
56
+ f.puts ::Process.pid
57
+ end
58
+ end
59
+
60
+ end
61
+ end