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.
- data/lib/our-eel-hacks/autoscaler.rb +199 -0
- data/lib/our-eel-hacks/defer/event-machine.rb +12 -0
- data/lib/our-eel-hacks/middleware.rb +15 -0
- data/lib/our-eel-hacks/rack.rb +29 -0
- data/lib/our-eel-hacks/sidekiq.rb +23 -0
- data/spec/autoscaler.rb +187 -0
- data/spec/rack.rb +42 -0
- data/spec_help/cassettes/OurEelHacks_Autoscaler.yml +395 -0
- data/spec_help/cassettes/OurEelHacks_Rack.yml +640 -0
- data/spec_help/file-sandbox.rb +164 -0
- data/spec_help/gem_test_suite.rb +17 -0
- data/spec_help/spec_helper.rb +23 -0
- metadata +93 -0
@@ -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,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
|
data/spec/autoscaler.rb
ADDED
@@ -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
|