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