our-eel-hacks 0.0.1

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,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