autoscale 0.9.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +81 -0
  3. data/Guardfile +12 -0
  4. data/README.md +81 -0
  5. data/examples/complex.rb +39 -0
  6. data/examples/simple.rb +28 -0
  7. data/lib/autoscaler.rb +5 -0
  8. data/lib/autoscaler/binary_scaling_strategy.rb +26 -0
  9. data/lib/autoscaler/counter_cache_memory.rb +35 -0
  10. data/lib/autoscaler/counter_cache_redis.rb +50 -0
  11. data/lib/autoscaler/delayed_shutdown.rb +44 -0
  12. data/lib/autoscaler/heroku_scaler.rb +81 -0
  13. data/lib/autoscaler/ignore_scheduled_and_retrying.rb +13 -0
  14. data/lib/autoscaler/linear_scaling_strategy.rb +39 -0
  15. data/lib/autoscaler/sidekiq.rb +11 -0
  16. data/lib/autoscaler/sidekiq/activity.rb +62 -0
  17. data/lib/autoscaler/sidekiq/celluloid_monitor.rb +67 -0
  18. data/lib/autoscaler/sidekiq/client.rb +50 -0
  19. data/lib/autoscaler/sidekiq/entire_queue_system.rb +41 -0
  20. data/lib/autoscaler/sidekiq/monitor_middleware_adapter.rb +46 -0
  21. data/lib/autoscaler/sidekiq/queue_system.rb +20 -0
  22. data/lib/autoscaler/sidekiq/sleep_wait_server.rb +51 -0
  23. data/lib/autoscaler/sidekiq/specified_queue_system.rb +48 -0
  24. data/lib/autoscaler/stub_scaler.rb +25 -0
  25. data/lib/autoscaler/version.rb +4 -0
  26. data/spec/autoscaler/binary_scaling_strategy_spec.rb +19 -0
  27. data/spec/autoscaler/counter_cache_memory_spec.rb +21 -0
  28. data/spec/autoscaler/counter_cache_redis_spec.rb +49 -0
  29. data/spec/autoscaler/delayed_shutdown_spec.rb +23 -0
  30. data/spec/autoscaler/heroku_scaler_spec.rb +49 -0
  31. data/spec/autoscaler/ignore_scheduled_and_retrying_spec.rb +33 -0
  32. data/spec/autoscaler/linear_scaling_strategy_spec.rb +85 -0
  33. data/spec/autoscaler/sidekiq/activity_spec.rb +34 -0
  34. data/spec/autoscaler/sidekiq/celluloid_monitor_spec.rb +39 -0
  35. data/spec/autoscaler/sidekiq/client_spec.rb +35 -0
  36. data/spec/autoscaler/sidekiq/entire_queue_system_spec.rb +65 -0
  37. data/spec/autoscaler/sidekiq/monitor_middleware_adapter_spec.rb +16 -0
  38. data/spec/autoscaler/sidekiq/sleep_wait_server_spec.rb +45 -0
  39. data/spec/autoscaler/sidekiq/specified_queue_system_spec.rb +63 -0
  40. data/spec/spec_helper.rb +16 -0
  41. data/spec/test_system.rb +11 -0
  42. metadata +187 -0
@@ -0,0 +1,25 @@
1
+ require 'heroku-api'
2
+
3
+ module Autoscaler
4
+ # A minimal scaler to use as stub for local testing
5
+ class StubScaler
6
+ # @param [String] type used to distinguish messages from multiple stubs
7
+ def initialize(type = 'worker')
8
+ @type = type
9
+ @workers = 0
10
+ end
11
+
12
+ attr_reader :type
13
+
14
+ # Read the current worker count
15
+ # @return [Numeric] number of workers
16
+ attr_reader :workers
17
+
18
+ # Set the number of workers
19
+ # @param [Numeric] n number of workers
20
+ def workers=(n)
21
+ p "Scaling #{type} to #{n}"
22
+ @workers = n
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,4 @@
1
+ module Autoscaler
2
+ # version number
3
+ VERSION = "0.9.1"
4
+ end
@@ -0,0 +1,19 @@
1
+ require 'spec_helper'
2
+ require 'test_system'
3
+ require 'autoscaler/binary_scaling_strategy'
4
+
5
+ describe Autoscaler::BinaryScalingStrategy do
6
+ let(:cut) {Autoscaler::BinaryScalingStrategy}
7
+
8
+ it "scales with no work" do
9
+ system = TestSystem.new(0)
10
+ strategy = cut.new
11
+ strategy.call(system, 1).should == 0
12
+ end
13
+
14
+ it "does not scale with pending work" do
15
+ system = TestSystem.new(1)
16
+ strategy = cut.new(2)
17
+ strategy.call(system, 1).should == 2
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ require 'spec_helper'
2
+ require 'autoscaler/counter_cache_memory'
3
+
4
+ describe Autoscaler::CounterCacheMemory do
5
+ let(:cut) {Autoscaler::CounterCacheMemory}
6
+
7
+ it {expect{cut.new.counter}.to raise_error(cut::Expired)}
8
+ it {cut.new.counter{1}.should == 1}
9
+
10
+ it 'set and store' do
11
+ cache = cut.new
12
+ cache.counter = 1
13
+ cache.counter.should == 1
14
+ end
15
+
16
+ it 'times out' do
17
+ cache = cut.new(0)
18
+ cache.counter = 1
19
+ expect{cache.counter.should}.to raise_error(cut::Expired)
20
+ end
21
+ end
@@ -0,0 +1,49 @@
1
+ require 'spec_helper'
2
+ require 'autoscaler/counter_cache_redis'
3
+
4
+ describe Autoscaler::CounterCacheRedis do
5
+ before do
6
+ @redis = Sidekiq.redis = REDIS
7
+ Sidekiq.redis {|c| c.flushdb }
8
+ end
9
+
10
+ let(:cut) {Autoscaler::CounterCacheRedis}
11
+ subject {cut.new(Sidekiq.method(:redis))}
12
+
13
+ it {expect{subject.counter}.to raise_error(cut::Expired)}
14
+ it {subject.counter{1}.should == 1}
15
+
16
+ it 'set and store' do
17
+ subject.counter = 2
18
+ subject.counter.should == 2
19
+ end
20
+
21
+ it 'does not conflict with multiple worker types' do
22
+ other_worker_cache = cut.new(@redis, 300, 'other_worker')
23
+ subject.counter = 1
24
+ other_worker_cache.counter = 2
25
+
26
+ subject.counter.should == 1
27
+ other_worker_cache.counter = 2
28
+ end
29
+
30
+ it 'times out' do
31
+ cache = cut.new(Sidekiq.method(:redis), 1) # timeout 0 invalid
32
+ cache.counter = 3
33
+ sleep(2)
34
+ expect{cache.counter}.to raise_error(cut::Expired)
35
+ end
36
+
37
+ it 'passed a connection pool' do
38
+ cache = cut.new(@redis)
39
+ cache.counter = 4
40
+ cache.counter.should == 4
41
+ end
42
+
43
+ it 'passed a plain connection' do
44
+ connection = Redis.connect(:url => 'http://localhost:9736', :namespace => 'autoscaler')
45
+ cache = cut.new connection
46
+ cache.counter = 5
47
+ cache.counter.should == 5
48
+ end
49
+ end
@@ -0,0 +1,23 @@
1
+ require 'spec_helper'
2
+ require 'test_system'
3
+ require 'autoscaler/delayed_shutdown'
4
+
5
+ describe Autoscaler::DelayedShutdown do
6
+ let(:cut) {Autoscaler::DelayedShutdown}
7
+
8
+ it "returns normal values" do
9
+ strategy = cut.new(lambda{|s,t| 2}, 0)
10
+ strategy.call(nil, 1).should == 2
11
+ end
12
+
13
+ it "delays zeros" do
14
+ strategy = cut.new(lambda{|s,t| 0}, 60)
15
+ strategy.call(nil, 1).should == 1
16
+ end
17
+
18
+ it "eventually returns zero" do
19
+ strategy = cut.new(lambda{|s,t| 0}, 60)
20
+ strategy.stub(:level_idle_time).and_return(61)
21
+ strategy.call(nil, 61).should == 0
22
+ end
23
+ end
@@ -0,0 +1,49 @@
1
+ require 'spec_helper'
2
+ require 'autoscaler/heroku_scaler'
3
+ require 'heroku/api/errors'
4
+
5
+ describe Autoscaler::HerokuScaler, :online => true do
6
+ let(:cut) {Autoscaler::HerokuScaler}
7
+ let(:client) {cut.new}
8
+ subject {client}
9
+
10
+ its(:workers) {should == 0}
11
+
12
+ describe 'scaled' do
13
+ around do |example|
14
+ client.workers = 1
15
+ example.yield
16
+ client.workers = 0
17
+ end
18
+
19
+ its(:workers) {should == 1}
20
+ end
21
+
22
+ shared_examples 'exception handler' do |exception_class|
23
+ before do
24
+ client.should_receive(:client){
25
+ raise exception_class.new(Exception.new('oops'))
26
+ }
27
+ end
28
+
29
+ describe "default handler" do
30
+ it {expect{client.workers}.to_not raise_error}
31
+ it {client.workers.should == 0}
32
+ it {expect{client.workers = 2}.to_not raise_error}
33
+ end
34
+
35
+ describe "custom handler" do
36
+ before do
37
+ @caught = false
38
+ client.exception_handler = lambda {|exception| @caught = true}
39
+ end
40
+
41
+ it {client.workers; @caught.should be_true}
42
+ end
43
+ end
44
+
45
+ describe 'exception handling', :focus => true do
46
+ it_behaves_like 'exception handler', Excon::Errors::SocketError
47
+ it_behaves_like 'exception handler', Heroku::API::Errors::Error
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ require 'spec_helper'
2
+ require 'test_system'
3
+ require 'autoscaler/ignore_scheduled_and_retrying'
4
+
5
+ describe Autoscaler::IgnoreScheduledAndRetrying do
6
+ let(:cut) {Autoscaler::IgnoreScheduledAndRetrying}
7
+
8
+ it "passes through enqueued" do
9
+ system = Struct.new(:enqueued).new(3)
10
+ strategy = proc {|system, time| system.enqueued}
11
+ cut.new(strategy).call(system, 0).should == 3
12
+ end
13
+
14
+ it "passes through workers" do
15
+ system = Struct.new(:workers).new(3)
16
+ strategy = proc {|system, time| system.workers}
17
+ cut.new(strategy).call(system, 0).should == 3
18
+ end
19
+
20
+ it "ignores scheduled" do
21
+ system = Struct.new(:scheduled).new(3)
22
+ strategy = proc {|system, time| system.scheduled}
23
+ cut.new(strategy).call(system, 0).should == 0
24
+ end
25
+
26
+ it "ignores retrying" do
27
+ system = Struct.new(:retrying).new(3)
28
+ strategy = proc {|system, time| system.retrying}
29
+ cut.new(strategy).call(system, 0).should == 0
30
+ end
31
+ end
32
+
33
+
@@ -0,0 +1,85 @@
1
+ require 'spec_helper'
2
+ require 'test_system'
3
+ require 'autoscaler/linear_scaling_strategy'
4
+
5
+ describe Autoscaler::LinearScalingStrategy do
6
+ let(:cut) {Autoscaler::LinearScalingStrategy}
7
+
8
+ it "deactivates with no work" do
9
+ system = TestSystem.new(0)
10
+ strategy = cut.new(1)
11
+ strategy.call(system, 1).should == 0
12
+ end
13
+
14
+ it "activates with some work" do
15
+ system = TestSystem.new(1)
16
+ strategy = cut.new(1)
17
+ strategy.call(system, 1).should be > 0
18
+ end
19
+
20
+ it "minimally scales with minimal work" do
21
+ system = TestSystem.new(1)
22
+ strategy = cut.new(2, 2)
23
+ strategy.call(system, 1).should == 1
24
+ end
25
+
26
+ it "maximally scales with too much work" do
27
+ system = TestSystem.new(5)
28
+ strategy = cut.new(2, 2)
29
+ strategy.call(system, 1).should == 2
30
+ end
31
+
32
+ it "proportionally scales with some work" do
33
+ system = TestSystem.new(5)
34
+ strategy = cut.new(5, 2)
35
+ strategy.call(system, 1).should == 3
36
+ end
37
+
38
+ it "doesn't scale unless minimum is met" do
39
+ system = TestSystem.new(2)
40
+ strategy = cut.new(10, 4, 0.5)
41
+ strategy.call(system, 1).should == 0
42
+ end
43
+
44
+ it "scales proprotionally with a minimum" do
45
+ system = TestSystem.new(3)
46
+ strategy = cut.new(10, 4, 0.5)
47
+ strategy.call(system, 1).should == 1
48
+ end
49
+
50
+ it "scales maximally with a minimum" do
51
+ system = TestSystem.new(25)
52
+ strategy = cut.new(5, 4, 0.5)
53
+ strategy.call(system, 1).should == 5
54
+ end
55
+
56
+ it "scales proportionally with a minimum > 1" do
57
+ system = TestSystem.new(12)
58
+ strategy = cut.new(5, 4, 2)
59
+ strategy.call(system, 1).should == 2
60
+ end
61
+
62
+ it "scales maximally with a minimum factor > 1" do
63
+ system = TestSystem.new(30)
64
+ strategy = cut.new(5, 4, 2)
65
+ strategy.call(system, 1).should == 5
66
+ end
67
+
68
+ it "doesn't scale down engaged workers" do
69
+ system = TestSystem.new(0, 2)
70
+ strategy = cut.new(5, 4)
71
+ strategy.call(system, 1).should == 2
72
+ end
73
+
74
+ it "doesn't scale above max workers even if engaged workers is greater" do
75
+ system = TestSystem.new(40, 6)
76
+ strategy = cut.new(5, 4)
77
+ strategy.call(system, 1).should == 5
78
+ end
79
+
80
+ it "returns zero if requested capacity is zero" do
81
+ system = TestSystem.new(0, 0)
82
+ strategy = cut.new(0, 0)
83
+ strategy.call(system, 5).should == 0
84
+ end
85
+ end
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+ require 'autoscaler/sidekiq/activity'
3
+
4
+ describe Autoscaler::Sidekiq::Activity do
5
+ before do
6
+ @redis = Sidekiq.redis = REDIS
7
+ Sidekiq.redis {|c| c.flushdb }
8
+ end
9
+
10
+ let(:cut) {Autoscaler::Sidekiq::Activity}
11
+ let(:activity) {cut.new(0)}
12
+
13
+ context "when another process is working" do
14
+ let(:other_process) {cut.new(10)}
15
+ before do
16
+ activity.idle!('queue')
17
+ other_process.working!('other_queue')
18
+ end
19
+ it {activity.should be_idle(['queue'])}
20
+ end
21
+
22
+ it 'passed a connection pool' do
23
+ activity = cut.new(5, @redis)
24
+ activity.working!('queue')
25
+ activity.should_not be_idle(['queue'])
26
+ end
27
+
28
+ it 'passed a plain connection' do
29
+ connection = Redis.connect(:url => 'http://localhost:9736', :namespace => 'autoscaler')
30
+ activity = cut.new(5, connection)
31
+ activity.working!('queue')
32
+ activity.should_not be_idle(['queue'])
33
+ end
34
+ end
@@ -0,0 +1,39 @@
1
+ require 'spec_helper'
2
+ require 'test_system'
3
+ require 'autoscaler/sidekiq/celluloid_monitor'
4
+ require 'timeout'
5
+
6
+ describe Autoscaler::Sidekiq::CelluloidMonitor do
7
+ before do
8
+ @redis = Sidekiq.redis = REDIS
9
+ Sidekiq.redis {|c| c.flushdb }
10
+ end
11
+
12
+ let(:cut) {Autoscaler::Sidekiq::CelluloidMonitor}
13
+ let(:scaler) {TestScaler.new(1)}
14
+
15
+ it "scales with no work" do
16
+ system = TestSystem.new(0)
17
+ manager = cut.new(scaler, lambda{|s,t| 0}, system)
18
+ Timeout.timeout(1) { manager.wait_for_downscale(0.5) }
19
+ scaler.workers.should == 0
20
+ manager.terminate
21
+ end
22
+
23
+ it "does not scale with pending work" do
24
+ system = TestSystem.new(1)
25
+ manager = cut.new(scaler, lambda{|s,t| 1}, system)
26
+ expect {Timeout.timeout(1) { manager.wait_for_downscale(0.5) }}.to raise_error Timeout::Error
27
+ scaler.workers.should == 1
28
+ manager.terminate
29
+ end
30
+
31
+ it "will downscale with initial workers zero" do
32
+ system = TestSystem.new(0)
33
+ scaler = TestScaler.new(0)
34
+ manager = cut.new(scaler, lambda{|s,t| 0}, system)
35
+ Timeout.timeout(1) { manager.wait_for_downscale(0.5) }
36
+ scaler.workers.should == 0
37
+ manager.terminate
38
+ end
39
+ end
@@ -0,0 +1,35 @@
1
+ require 'spec_helper'
2
+ require 'test_system'
3
+ require 'autoscaler/sidekiq/client'
4
+
5
+ describe Autoscaler::Sidekiq::Client do
6
+ let(:cut) {Autoscaler::Sidekiq::Client}
7
+ let(:scaler) {TestScaler.new(0)}
8
+ let(:client) {cut.new('queue' => scaler)}
9
+
10
+ describe 'call' do
11
+ it 'scales' do
12
+ client.call(Class, {}, 'queue') {}
13
+ scaler.workers.should == 1
14
+ end
15
+
16
+ it 'scales with a redis pool' do
17
+ client.call(Class, {}, 'queue', ::Sidekiq.method(:redis)) {}
18
+ scaler.workers.should == 1
19
+ end
20
+
21
+ it('yields') {client.call(Class, {}, 'queue') {:foo}.should == :foo}
22
+ end
23
+
24
+ describe 'initial workers' do
25
+ it 'works with default arguments' do
26
+ client.set_initial_workers
27
+ scaler.workers.should == 0
28
+ end
29
+
30
+ it 'scales when necessary' do
31
+ client.set_initial_workers {|q| TestSystem.new(1)}
32
+ scaler.workers.should == 1
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,65 @@
1
+ require 'spec_helper'
2
+ require 'autoscaler/sidekiq/entire_queue_system'
3
+
4
+ describe Autoscaler::Sidekiq::EntireQueueSystem do
5
+ let(:cut) {Autoscaler::Sidekiq::EntireQueueSystem}
6
+
7
+ before do
8
+ @redis = Sidekiq.redis = REDIS
9
+ Sidekiq.redis {|c| c.flushdb }
10
+ end
11
+
12
+ def with_work_in_set(queue, set)
13
+ payload = Sidekiq.dump_json('queue' => queue)
14
+ Sidekiq.redis { |c| c.zadd(set, (Time.now.to_f + 30.to_f).to_s, payload)}
15
+ end
16
+
17
+ def with_scheduled_work_in(queue)
18
+ with_work_in_set(queue, 'schedule')
19
+ end
20
+
21
+ def with_retry_work_in(queue)
22
+ with_work_in_set(queue, 'retry')
23
+ end
24
+
25
+ subject {cut.new}
26
+
27
+ it {subject.queue_names.should == []}
28
+ it {subject.workers.should == 0}
29
+
30
+ describe 'no queued work' do
31
+ it "with no work" do
32
+ subject.stub(:sidekiq_queues).and_return({'queue' => 0, 'another_queue' => 0})
33
+ subject.queued.should == 0
34
+ end
35
+
36
+ it "with no work and no queues" do
37
+ subject.queued.should == 0
38
+ end
39
+
40
+ it "with no scheduled work" do
41
+ subject.scheduled.should == 0
42
+ end
43
+
44
+ it "with no retry work" do
45
+ subject.retrying.should == 0
46
+ end
47
+ end
48
+
49
+ describe 'with queued work' do
50
+ it "with enqueued work" do
51
+ subject.stub(:sidekiq_queues).and_return({'queue' => 1})
52
+ subject.queued.should == 1
53
+ end
54
+
55
+ it "with schedule work" do
56
+ with_scheduled_work_in('queue')
57
+ subject.scheduled.should == 1
58
+ end
59
+
60
+ it "with retry work" do
61
+ with_retry_work_in('queue')
62
+ subject.retrying.should == 1
63
+ end
64
+ end
65
+ end