autoscale 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
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,13 @@
1
+ module Autoscaler
2
+ class IgnoreScheduledAndRetrying
3
+ def initialize(strategy)
4
+ @strategy = strategy
5
+ end
6
+
7
+ def call(system, event_idle_time)
8
+ system.define_singleton_method(:scheduled) { 0 }
9
+ system.define_singleton_method(:retrying) { 0 }
10
+ @strategy.call(system, event_idle_time)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,39 @@
1
+ module Autoscaler
2
+ # Strategies determine the target number of workers
3
+ # This strategy sets the number of workers to be proportional to the number of enqueued jobs.
4
+ class LinearScalingStrategy
5
+ #@param [integer] max_workers maximum number of workers to spin up.
6
+ #@param [integer] worker_capacity the amount of jobs one worker can handle
7
+ #@param [float] min_factor minimum work required to scale, as percentage of worker_capacity
8
+ def initialize(max_workers = 1, worker_capacity = 25, min_factor = 0)
9
+ @max_workers = max_workers # max # of workers we can scale to
10
+ @total_capacity = (@max_workers * worker_capacity).to_f # total capacity of max workers
11
+ min_capacity = [0, min_factor].max.to_f * worker_capacity # min capacity required to scale first worker
12
+ @min_capacity_percentage = min_capacity / @total_capacity # min percentage of total capacity
13
+ end
14
+
15
+ # @param [QueueSystem] system interface to the queuing system
16
+ # @param [Numeric] event_idle_time number of seconds since a job related event
17
+ # @return [Integer] target number of workers
18
+ def call(system, event_idle_time)
19
+ requested_capacity_percentage = total_work(system) / @total_capacity
20
+
21
+ # Scale requested capacity taking into account the minimum required
22
+ scale_factor = (requested_capacity_percentage - @min_capacity_percentage) / (@total_capacity - @min_capacity_percentage)
23
+ scale_factor = 0 if scale_factor.nan? # Handle DIVZERO
24
+
25
+ scaled_capacity_percentage = scale_factor * @total_capacity
26
+
27
+ ideal_workers = ([0, scaled_capacity_percentage].max * @max_workers).ceil
28
+ min_workers = [system.workers, ideal_workers].max # Don't scale down past number of currently engaged workers
29
+ max_workers = [min_workers, @max_workers].min # Don't scale up past number of max workers
30
+
31
+ return [min_workers, max_workers].min
32
+ end
33
+
34
+ private
35
+ def total_work(system)
36
+ system.queued + system.scheduled + system.retrying
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,11 @@
1
+ require 'autoscaler/sidekiq/client'
2
+ require 'autoscaler/sidekiq/monitor_middleware_adapter'
3
+
4
+ module Autoscaler
5
+ # namespace module for Sidekiq middlewares
6
+ module Sidekiq
7
+ # Sidekiq server middleware
8
+ # Performs scale-down when the queue is empty
9
+ Server = MonitorMiddlewareAdapter
10
+ end
11
+ end
@@ -0,0 +1,62 @@
1
+ require 'sidekiq'
2
+
3
+ module Autoscaler
4
+ module Sidekiq
5
+ # Tracks activity timeouts using Sidekiq's redis connection
6
+ class Activity
7
+ # @param [Numeric] timeout number of seconds to wait before shutdown
8
+ def initialize(timeout, redis = ::Sidekiq.method(:redis))
9
+ @timeout = timeout
10
+ @redis = redis
11
+ end
12
+
13
+ # Record that a queue has activity
14
+ # @param [String] queue
15
+ def working!(queue)
16
+ active_at queue, Time.now
17
+ end
18
+
19
+ # Record that a queue is idle and timed out - mostly for test support
20
+ # @param [String] queue
21
+ def idle!(queue)
22
+ active_at queue, Time.now - timeout*2
23
+ end
24
+
25
+ # Have the watched queues timed out?
26
+ # @param [Array[String]] queues list of queues to monitor to determine if there is work left
27
+ # @return [boolean]
28
+ def idle?(queues)
29
+ idle_time(queues) > timeout
30
+ end
31
+
32
+ private
33
+ attr_reader :timeout
34
+
35
+ def idle_time(queues)
36
+ t = last_activity(queues)
37
+ return 0 unless t
38
+ Time.now - Time.parse(t)
39
+ end
40
+
41
+ def last_activity(queues)
42
+ redis {|c|
43
+ queues.map {|q| c.get('background_activity:'+q)}.compact.max
44
+ }
45
+ end
46
+
47
+ def active_at(queue, time)
48
+ redis {|c| c.set('background_activity:'+queue, time)}
49
+ end
50
+
51
+ def redis(&block)
52
+ if @redis.respond_to?(:call)
53
+ @redis.call(&block)
54
+ elsif @redis.respond_to?(:with)
55
+ @redis.with(&block)
56
+ else
57
+ block.call(@redis)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,67 @@
1
+ require 'celluloid'
2
+
3
+ module Autoscaler
4
+ module Sidekiq
5
+ # Actor to monitor the sidekiq server for scale-down
6
+ class CelluloidMonitor
7
+ include Celluloid
8
+
9
+ # @param [scaler] scaler object that actually performs scaling operations (e.g. {HerokuScaler})
10
+ # @param [Strategy] strategy object that decides the target number of workers (e.g. {BinaryScalingStrategy})
11
+ # @param [System] system interface to the queuing system for use by the strategy
12
+ def initialize(scaler, strategy, system)
13
+ @scaler = scaler
14
+ @strategy = strategy
15
+ @system = system
16
+ @running = false
17
+ end
18
+
19
+ # Periodically update the desired number of workers
20
+ # @param [Numeric] interval polling interval, mostly for testing
21
+ def wait_for_downscale(interval = 15)
22
+ once do
23
+ active_now!
24
+
25
+ workers = :unknown
26
+
27
+ begin
28
+ sleep(interval)
29
+ target_workers = @strategy.call(@system, idle_time)
30
+ workers = @scaler.workers = target_workers unless workers == target_workers
31
+ end while workers > 0
32
+ end
33
+ end
34
+
35
+ # Notify the monitor that a job is starting
36
+ def starting_job
37
+ end
38
+
39
+ # Notify the monitor that a job has finished
40
+ def finished_job
41
+ active_now!
42
+ async.wait_for_downscale
43
+ end
44
+
45
+ private
46
+
47
+ def active_now!
48
+ @activity = Time.now
49
+ end
50
+
51
+ def idle_time
52
+ Time.now - @activity
53
+ end
54
+
55
+ def once
56
+ return if @running
57
+
58
+ begin
59
+ @running = true
60
+ yield
61
+ ensure
62
+ @running = false
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,50 @@
1
+ require 'autoscaler/binary_scaling_strategy'
2
+ require 'autoscaler/sidekiq/specified_queue_system'
3
+
4
+ module Autoscaler
5
+ module Sidekiq
6
+ # Sidekiq client middleware
7
+ # Performs scale-up when items are queued and there are no workers running
8
+ class Client
9
+ # @param [Hash] scalers map of queue(String) => scaler (e.g. {HerokuScaler}).
10
+ # Which scaler to use for each sidekiq queue
11
+ def initialize(scalers)
12
+ @scalers = scalers
13
+ end
14
+
15
+ # Sidekiq middleware api method
16
+ def call(worker_class, item, queue, _ = nil)
17
+ result = yield
18
+
19
+ scaler = @scalers[queue]
20
+ if scaler && scaler.workers < 1
21
+ scaler.workers = 1
22
+ end
23
+
24
+ result
25
+ end
26
+
27
+ # Check for interrupted or scheduled work on startup.
28
+ # Typically you need to construct your own instance just
29
+ # to call this method, but see add_to_chain.
30
+ # @param [Strategy] strategy object that determines target workers
31
+ # @yieldparam [String] queue mostly for testing
32
+ # @yieldreturn [QueueSystem] mostly for testing
33
+ def set_initial_workers(strategy = nil, &system_factory)
34
+ strategy ||= BinaryScalingStrategy.new
35
+ system_factory ||= lambda {|queue| SpecifiedQueueSystem.new([queue])}
36
+ @scalers.each do |queue, scaler|
37
+ scaler.workers = strategy.call(system_factory.call(queue), 0)
38
+ end
39
+ end
40
+
41
+ # Convenience method to avoid having to name the class and parameter
42
+ # twice when calling set_initial_workers
43
+ # @return [Client] an instance of Client for set_initial_workers
44
+ def self.add_to_chain(chain, scalers)
45
+ chain.add self, scalers
46
+ new(scalers)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,41 @@
1
+ require 'sidekiq/api'
2
+
3
+ module Autoscaler
4
+ module Sidekiq
5
+ # Interface to to interrogate the queuing system
6
+ # Includes every queue
7
+ class EntireQueueSystem
8
+ # @return [Integer] number of workers actively engaged
9
+ def workers
10
+ ::Sidekiq::Workers.new.map {|pid, _, _| pid}.uniq.size
11
+ # #size may be out-of-date.
12
+ end
13
+
14
+ # @return [Integer] amount work ready to go
15
+ def queued
16
+ sidekiq_queues.values.map(&:to_i).reduce(&:+) || 0
17
+ end
18
+
19
+ # @return [Integer] amount of work scheduled for some time in the future
20
+ def scheduled
21
+ ::Sidekiq::ScheduledSet.new.size
22
+ end
23
+
24
+ # @return [Integer] amount of work still being retried
25
+ def retrying
26
+ ::Sidekiq::RetrySet.new.size
27
+ end
28
+
29
+ # @return [Array[String]]
30
+ def queue_names
31
+ sidekiq_queues.keys
32
+ end
33
+
34
+ private
35
+
36
+ def sidekiq_queues
37
+ ::Sidekiq::Stats.new.queues
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,46 @@
1
+ require 'autoscaler/sidekiq/queue_system'
2
+ require 'autoscaler/sidekiq/celluloid_monitor'
3
+ require 'autoscaler/binary_scaling_strategy'
4
+ require 'autoscaler/delayed_shutdown'
5
+
6
+ module Autoscaler
7
+ module Sidekiq
8
+ # Shim to the existing autoscaler interface
9
+ # Starts the monitor and notifies it of job events that may occur while it's sleeping
10
+ class MonitorMiddlewareAdapter
11
+ # @param [scaler] scaler object that actually performs scaling operations (e.g. {HerokuScaler})
12
+ # @param [Strategy,Numeric] timeout strategy object that determines target workers, or a timeout in seconds to be passed to {DelayedShutdown}+{BinaryScalingStrategy}
13
+ # @param [Array[String]] specified_queues list of queues to monitor to determine if there is work left. Defaults to all sidekiq queues.
14
+ def initialize(scaler, timeout, specified_queues = nil)
15
+ unless monitor
16
+ CelluloidMonitor.supervise_as(:autoscaler_monitor,
17
+ scaler,
18
+ strategy(timeout),
19
+ QueueSystem.new(specified_queues))
20
+ end
21
+ end
22
+
23
+ # Sidekiq middleware api entry point
24
+ def call(worker, msg, queue, _ = nil)
25
+ monitor.async.starting_job
26
+ yield
27
+ ensure
28
+ # monitor might have gone, e.g. if Sidekiq has received SIGTERM
29
+ monitor.async.finished_job if monitor
30
+ end
31
+
32
+ private
33
+ def monitor
34
+ Celluloid::Actor[:autoscaler_monitor]
35
+ end
36
+
37
+ def strategy(timeout)
38
+ if timeout.respond_to?(:call)
39
+ timeout
40
+ else
41
+ DelayedShutdown.new(BinaryScalingStrategy.new, timeout)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ require 'sidekiq/api'
2
+ require 'autoscaler/sidekiq/specified_queue_system'
3
+ require 'autoscaler/sidekiq/entire_queue_system'
4
+
5
+ module Autoscaler
6
+ module Sidekiq
7
+ # Interface to to interrogate the queuing system
8
+ # convenience constructor for SpecifiedQueueSystem and EntireQueueSystem
9
+ module QueueSystem
10
+ # @param [Array[String]] specified_queues list of queues to monitor to determine if there is work left. Defaults to all sidekiq queues.
11
+ def self.new(specified_queues = nil)
12
+ if specified_queues
13
+ SpecifiedQueueSystem.new(specified_queues)
14
+ else
15
+ EntireQueueSystem.new
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,51 @@
1
+ require 'autoscaler/sidekiq/queue_system'
2
+ require 'autoscaler/sidekiq/activity'
3
+
4
+ module Autoscaler
5
+ module Sidekiq
6
+ # Sidekiq server middleware
7
+ # Performs scale-down when the queue is empty
8
+ class SleepWaitServer
9
+ # @param [scaler] scaler object that actually performs scaling operations (e.g. {HerokuScaler})
10
+ # @param [Numeric] timeout number of seconds to wait before shutdown
11
+ # @param [Array[String]] specified_queues list of queues to monitor to determine if there is work left. Defaults to all sidekiq queues.
12
+ def initialize(scaler, timeout, specified_queues = nil)
13
+ @scaler = scaler
14
+ @timeout = timeout
15
+ @system = QueueSystem.new(specified_queues)
16
+ end
17
+
18
+ # Sidekiq middleware api entry point
19
+ def call(worker, msg, queue, redis = ::Sidekiq.method(:redis))
20
+ working!(queue, redis)
21
+ yield
22
+ ensure
23
+ working!(queue, redis)
24
+ wait_for_task_or_scale(redis)
25
+ end
26
+
27
+ private
28
+ def wait_for_task_or_scale(redis)
29
+ loop do
30
+ return if pending_work?
31
+ return @scaler.workers = 0 if idle?(redis)
32
+ sleep(0.5)
33
+ end
34
+ end
35
+
36
+ attr_reader :system
37
+
38
+ def pending_work?
39
+ system.queued > 0 || system.scheduled > 0 || system.retrying > 0
40
+ end
41
+
42
+ def working!(queue, redis)
43
+ Activity.new(@timeout, redis).working!(queue)
44
+ end
45
+
46
+ def idle?(redis)
47
+ Activity.new(@timeout, redis).idle?(system.queue_names)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,48 @@
1
+ require 'sidekiq/api'
2
+
3
+ module Autoscaler
4
+ module Sidekiq
5
+ # Interface to to interrogate the queuing system
6
+ # Includes only the queues provided to the constructor
7
+ class SpecifiedQueueSystem
8
+ # @param [Array[String]] specified_queues list of queues to monitor to determine if there is work left. Defaults to all sidekiq queues.
9
+ def initialize(specified_queues)
10
+ @queue_names = specified_queues
11
+ end
12
+
13
+ # @return [Integer] number of workers actively engaged
14
+ def workers
15
+ ::Sidekiq::Workers.new.select {|_, _, work|
16
+ queue_names.include?(work['queue'])
17
+ }.map {|pid, _, _| pid}.uniq.size
18
+ end
19
+
20
+ # @return [Integer] amount work ready to go
21
+ def queued
22
+ queue_names.map {|name| sidekiq_queues[name].to_i}.reduce(&:+)
23
+ end
24
+
25
+ # @return [Integer] amount of work scheduled for some time in the future
26
+ def scheduled
27
+ count_set(::Sidekiq::ScheduledSet.new)
28
+ end
29
+
30
+ # @return [Integer] amount of work still being retried
31
+ def retrying
32
+ count_set(::Sidekiq::RetrySet.new)
33
+ end
34
+
35
+ # @return [Array[String]]
36
+ attr_reader :queue_names
37
+
38
+ private
39
+ def sidekiq_queues
40
+ ::Sidekiq::Stats.new.queues
41
+ end
42
+
43
+ def count_set(set)
44
+ set.count { |job| queue_names.include?(job.queue) }
45
+ end
46
+ end
47
+ end
48
+ end