our-eel-hacks 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,199 @@
1
+ require 'heroku'
2
+
3
+ module OurEelHacks
4
+ class Autoscaler
5
+ class << self
6
+ def get_instance(flavor)
7
+ flavor = flavor.to_sym
8
+ @instances ||= Hash.new{ |h,k| h[k] = self.new }
9
+ return @instances[flavor]
10
+ end
11
+
12
+ def configure(flavor = :web, &block)
13
+ get_instance(flavor).configure(&block)
14
+ end
15
+
16
+ def instance_for(flavor = :web)
17
+ instance = get_instance(flavor)
18
+ instance.check_settings
19
+ return instance
20
+ end
21
+ end
22
+
23
+ class Limit
24
+ def initialize(soft, hard)
25
+ @soft = soft
26
+ @hard = hard
27
+ end
28
+
29
+ attr_accessor :hard, :soft
30
+ end
31
+
32
+ class UpperLimit < Limit
33
+ def includes?(value)
34
+ return (@soft < value && value <= @hard)
35
+ end
36
+
37
+ def >(value)
38
+ return @soft > value
39
+ end
40
+
41
+ def <(value)
42
+ return @hard < value
43
+ end
44
+ end
45
+
46
+ class LowerLimit < Limit
47
+ def includes?(value)
48
+ return (value >= @hard && value <= @soft)
49
+ end
50
+
51
+ def >(value)
52
+ return @hard > value
53
+ end
54
+
55
+ def <(value)
56
+ return @soft < value
57
+ end
58
+ end
59
+
60
+ def initialize()
61
+ @dynos = nil
62
+ @soft_side = nil
63
+
64
+ @last_scaled = 0
65
+ @entered_soft = nil
66
+ @last_reading = nil
67
+
68
+ @app_name = nil
69
+ @ps_type = nil
70
+ @heroku_api_key = nil
71
+
72
+ @min_dynos = 1
73
+ @max_dynos = 10
74
+ @lower_limits = LowerLimit.new(5, 1)
75
+ @upper_limits = UpperLimit.new(30, 50)
76
+ @soft_duration = 500
77
+ @scaling_frequency = 200
78
+ @logger = nil
79
+ end
80
+
81
+ def log(msg)
82
+ return if @logger.nil
83
+ @logger.info(msg)
84
+ end
85
+
86
+ def configure
87
+ yield self
88
+ check_settings
89
+
90
+ update_dynos(Time.now)
91
+ end
92
+
93
+ def check_settings
94
+ errors = []
95
+ errors << "No heroku api key set" if @heroku_api_key.nil?
96
+ errors << "No app name set" if @app_name.nil?
97
+ errors << "No process type set" if @ps_type.nil?
98
+ raise "OurEelHacks::Autoscaler, configuration problem: " + errors.join(", ") unless errors.empty?
99
+ end
100
+
101
+ attr_accessor :min_dynos, :max_dynos, :lower_limits, :upper_limits, :ps_type,
102
+ :soft_duration, :scaling_frequency, :logger, :heroku_api_key, :app_name
103
+ attr_reader :last_scaled, :dynos, :entered_soft, :last_reading, :soft_side
104
+
105
+ def elapsed(start, finish)
106
+ seconds = finish.to_i - start.to_i
107
+ millis = finish.usec - start.usec
108
+ return seconds * 1000 + millis
109
+ end
110
+
111
+ def scale(metric)
112
+ moment = Time.now
113
+ if elapsed(last_scaled, moment) < scaling_frequency
114
+ return
115
+ end
116
+
117
+ target_dynos = target_scale(metric, moment)
118
+
119
+ target_dynos = [[target_dynos, max_dynos].min, min_dynos].max
120
+
121
+ set_dynos(target_dynos)
122
+
123
+ update_dynos(moment)
124
+ end
125
+
126
+ def target_scale(metric, moment)
127
+ if lower_limits > metric
128
+ return dynos - 1
129
+ elsif upper_limits < metric
130
+ return dynos + 1
131
+ elsif
132
+ result = (dynos + soft_limit(metric, moment))
133
+ return result
134
+ end
135
+ end
136
+
137
+ def soft_limit(metric, moment)
138
+ hit_limit = [lower_limits, upper_limits].find{|lim| lim.includes? metric}
139
+
140
+ if soft_side == hit_limit
141
+ if elapsed(entered_soft, moment) > soft_duration
142
+ entered_soft = moment
143
+ case hit_limit
144
+ when upper_limits
145
+ return +1
146
+ when lower_limits
147
+ return -1
148
+ else
149
+ return 0
150
+ end
151
+ else
152
+ return 0
153
+ end
154
+ else
155
+ @entered_soft = moment
156
+ end
157
+
158
+ @soft_side = hit_limit
159
+ return 0
160
+ end
161
+
162
+ def dyno_info
163
+ regexp = /^#{ps_type}[.].*/
164
+ return heroku.ps(app_name).find_all do |dyno|
165
+ dyno["process"] =~ regexp
166
+ end
167
+ end
168
+
169
+ def update_dynos(moment)
170
+ new_value = dyno_info.count
171
+ if new_value != dynos
172
+ @last_scaled = moment
173
+ @entered_soft = moment
174
+ end
175
+ @dynos = new_value
176
+ @last_reading = moment
177
+ end
178
+
179
+ def dynos_stable?
180
+ return dyno_info.all? do |dyno|
181
+ dyno["state"] == "up"
182
+ end
183
+ end
184
+
185
+ def heroku
186
+ @heroku ||= Heroku::Client.new("", heroku_api_key).tap do |client|
187
+ unless client.info(app_name)[:stack] == "cedar"
188
+ raise "#{self.class.name} built against cedar stack"
189
+ end
190
+ end
191
+ end
192
+
193
+ def set_dynos(count)
194
+ return if count == dynos or not dynos_stable?
195
+ heroko.ps_scale(app_name, :type => ps_type, :qty => count)
196
+ @last_scaled = Time.now
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,12 @@
1
+ require 'eventmachine'
2
+ module OurEelHacks
3
+ module Defer
4
+ module EventMachine
5
+ def autoscale(*args)
6
+ EM.defer do
7
+ super
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ require 'our-eel-hacks/autoscaler'
2
+
3
+ module OurEelHacks
4
+ class Middleware
5
+ def initialize(flavor)
6
+ @flavor = flavor
7
+ end
8
+
9
+ protected
10
+
11
+ def autoscale(metric)
12
+ Autoscaler.instance_for(@flavor).scale(metric)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,29 @@
1
+ require 'our-eel-hacks/middleware'
2
+ require 'our-eel-hacks/defer/event-machine'
3
+ module OurEelHacks
4
+ class Rack < Middleware
5
+ include Defer::EventMachine
6
+
7
+ def initialize(app, env_field, flavor = :web)
8
+ super(flavor)
9
+ @env_field = env_field
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ autoscale(metric_from(env))
15
+ ensure
16
+ @app.call(env)
17
+ end
18
+
19
+ def metric_from(env)
20
+ Integer(env[@env_field]) rescue 0
21
+ end
22
+ end
23
+
24
+ class ScaleOnRoutingQueue < Rack
25
+ def initialize(app, flavor = :web)
26
+ super(app, "HTTP_X_HEROKU_QUEUE_DEPTH", flavor)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ require 'our-eel-hacks/middleware'
2
+ require 'our-eel-hacks/defer/event-machine'
3
+
4
+ module OurEelHacks
5
+ class Sidekiq < Middleware
6
+ include Defer::EventMachine
7
+ def initialize(flavor=:sidekiq)
8
+ super
9
+ end
10
+
11
+ def call(worker_class, item, queue)
12
+ autoscale(get_queue_length(queue))
13
+ ensure
14
+ yield
15
+ end
16
+
17
+ def get_queue_length(queue)
18
+ ::Sidekiq.redis do |conn|
19
+ conn.llen("queue:#{queue}") || 0
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,187 @@
1
+ require 'spec_helper'
2
+ require 'our-eel-hacks/rack'
3
+
4
+ describe OurEelHacks::Autoscaler do
5
+ before :each do
6
+ FakeWeb.allow_net_connect = false
7
+ end
8
+
9
+ use_vcr_cassette :record => :once
10
+
11
+ let :app_name do
12
+ "sbmp"
13
+ end
14
+
15
+ let :api_key do
16
+ "FakeApiKey"
17
+ end
18
+
19
+ let :scaling_freq do
20
+ 200
21
+ end
22
+
23
+ let :soft_dur do
24
+ 500
25
+ end
26
+
27
+ let :ideal_value do
28
+ 25
29
+ end
30
+
31
+ let :soft_high do
32
+ 35
33
+ end
34
+
35
+ let :soft_low do
36
+ 3
37
+ end
38
+
39
+ let :hard_high do
40
+ 100
41
+ end
42
+
43
+ let :hard_low do
44
+ -10
45
+ end
46
+
47
+ let! :starting_time do
48
+ Time.now
49
+ end
50
+
51
+ def time_adjust(millis)
52
+ Time.stub!(:now).and_return(Time.at(starting_time, millis))
53
+ end
54
+
55
+ let :autoscaler do
56
+ time_adjust(0)
57
+ OurEelHacks::Autoscaler.new.tap do |test|
58
+ test.configure do |test|
59
+ test.app_name = app_name
60
+ test.heroku_api_key = api_key
61
+ test.ps_type = "web"
62
+ test.scaling_frequency = scaling_freq
63
+ test.soft_duration = soft_dur
64
+
65
+ test.lower_limits.hard = 1
66
+ test.lower_limits.soft = 5
67
+
68
+ test.upper_limits.soft = 30
69
+ test.upper_limits.hard = 50
70
+ end
71
+ end
72
+ end
73
+
74
+ it "should get a count of dynos at start" do
75
+ autoscaler.dynos.should == 3 #happens to be the number of web dynos right now
76
+ end
77
+
78
+ before :each do
79
+ autoscaler.stub!(:set_dynos)
80
+ time_adjust(0)
81
+ autoscaler.scale(ideal_value)
82
+ end
83
+
84
+ describe "scaling frequency" do
85
+
86
+ it "should not scale too soon" do
87
+ time_adjust(scaling_freq - 5)
88
+
89
+ autoscaler.should_not_receive(:set_dynos)
90
+ autoscaler.scale(hard_high)
91
+ end
92
+
93
+ it "should scale up if time has elapsed and hard limit exceeded" do
94
+ time_adjust(scaling_freq + 5)
95
+
96
+ autoscaler.should_receive(:set_dynos).with(4)
97
+ autoscaler.scale(hard_high)
98
+ end
99
+ end
100
+
101
+ describe "hard limits" do
102
+ before :each do
103
+ time_adjust(scaling_freq + 5)
104
+ end
105
+
106
+ it "should scale down if hard lower limit exceeded" do
107
+ autoscaler.should_receive(:set_dynos).with(2)
108
+ autoscaler.scale(hard_low)
109
+ end
110
+ end
111
+
112
+ describe "soft upper limit" do
113
+ before :each do
114
+ time_adjust(scaling_freq * 2)
115
+ autoscaler.scale(soft_high)
116
+ end
117
+
118
+ describe "if soft_duration hasn't elapsed" do
119
+ before :each do
120
+ time_adjust((scaling_freq * 2) + soft_dur - 5)
121
+ autoscaler.should_receive(:set_dynos).with(3)
122
+ end
123
+
124
+ it "should not scale up" do
125
+ autoscaler.scale(soft_high)
126
+ end
127
+
128
+ it "should not scale down" do
129
+ autoscaler.scale(soft_low)
130
+ end
131
+ end
132
+
133
+ describe "if soft_duration has elapsed" do
134
+ before :each do
135
+ time_adjust(scaling_freq * 2 + soft_dur + 5)
136
+ end
137
+
138
+ it "should scale up if above upper soft limit" do
139
+ autoscaler.should_receive(:set_dynos).with(4)
140
+ autoscaler.scale(soft_high)
141
+ end
142
+
143
+ it "should not scale down if below lower soft limit" do
144
+ autoscaler.should_receive(:set_dynos).with(3)
145
+ autoscaler.scale(soft_low)
146
+ end
147
+ end
148
+ end
149
+
150
+ describe "soft lower limit" do
151
+ before :each do
152
+ time_adjust(scaling_freq * 2)
153
+ autoscaler.scale(soft_low)
154
+ end
155
+
156
+ describe "if soft_duration hasn't elapsed" do
157
+ before :each do
158
+ time_adjust(scaling_freq * 2 + soft_dur - 5)
159
+ autoscaler.should_receive(:set_dynos).with(3)
160
+ end
161
+
162
+ it "should not scale up" do
163
+ autoscaler.scale(soft_high)
164
+ end
165
+
166
+ it "should not scale down" do
167
+ autoscaler.scale(soft_low)
168
+ end
169
+ end
170
+
171
+ describe "if soft_duration has elapsed" do
172
+ before :each do
173
+ time_adjust(scaling_freq * 2 + soft_dur + 5)
174
+ end
175
+
176
+ it "should not scale up even if above upper soft limit" do
177
+ autoscaler.should_receive(:set_dynos).with(3)
178
+ autoscaler.scale(soft_high)
179
+ end
180
+
181
+ it "should scale down if below lower soft limit" do
182
+ autoscaler.should_receive(:set_dynos).with(2)
183
+ autoscaler.scale(soft_low)
184
+ end
185
+ end
186
+ end
187
+ end
data/spec/rack.rb ADDED
@@ -0,0 +1,42 @@
1
+ require 'spec_helper'
2
+ require 'our-eel-hacks/rack'
3
+
4
+ describe OurEelHacks::Rack do
5
+ use_vcr_cassette :record => :once
6
+
7
+ let :app_name do
8
+ "sbmp"
9
+ end
10
+
11
+ let :api_key do
12
+ "FakeApiKey"
13
+ end
14
+
15
+ before :each do
16
+ OurEelHacks::Autoscaler.configure(:test) do |test|
17
+ test.app_name = app_name
18
+ test.heroku_api_key = api_key
19
+ test.ps_type = "web"
20
+ end
21
+ end
22
+
23
+ let :fake_app do
24
+ mock("Rack App").tap do |app|
25
+ app.stub!(:call)
26
+ end
27
+ end
28
+
29
+ let :env_field do
30
+ "HTTP_X_HEROKU_QUEUE_DEPTH"
31
+ end
32
+
33
+ let :middleware do
34
+ OurEelHacks::Rack.new(fake_app, env_field, :test)
35
+ end
36
+
37
+
38
+ it "should pass the metric to the autoscaler" do
39
+ OurEelHacks::Autoscaler.instance_for(:test).should_receive(:scale).with(100)
40
+ middleware.call({env_field => "100"})
41
+ end
42
+ end