sidekiq-heroku-autoscale 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3e8ad2ab9e6bdd803cecdf5f0148c5ef5cc25549fca06387e89ed449ff5ec635
4
+ data.tar.gz: 8997e2e8461d6c08735aa51b7bc9099ee17ef7aa4a42fda33ad96d9105fa12dd
5
+ SHA512:
6
+ metadata.gz: b1c57075a0d77844e06fe1d7940cdaa4243c74d9e9fbed2f1012583b9cf6dd41d72ac07c4bca032b0f814714315528450a18cca6f6332a09cb17a0c70cdc3b1f
7
+ data.tar.gz: d1911ced4e2fc584c334f920aec14b93778e0bbbc3871458c7020da9a55857a919fbe8817240437b5b58011f792de9d1a7158d38d4d8807c70bc5a6353c136a2
@@ -0,0 +1,165 @@
1
+ # Sidekiq Heroku Autoscale plugin
2
+
3
+ This [Sidekiq](https://github.com/mperham/sidekiq) plugin allows Heroku dynos to be started, stopped, and scaled based on job workload. Why? Because running non-stop Sidekiq dynos on Heroku can rack up unnecessary costs for apps with modest background processing needs.
4
+
5
+ This is a self-acknowledged rewrite of the [autoscaler](https://github.com/JustinLove/autoscaler) project. While this plugin borrows many foundation concepts from _autoscaler_, it rewrites core operations to address several logistical concerns and enable reporting through a web UI.
6
+
7
+ ![Web UI](web-preview.png)
8
+
9
+ Tested with Sidekiq 6, but should be compatible with other recent Sidekiq versions.
10
+
11
+ ## How it works
12
+
13
+ This plugin operates by tapping into Sidekiq startup hooks and middleware.
14
+
15
+ - Whenever a server is started or a job is queued, the appropriate process manager is called on to adjust its scale. Adjustments are throttled across process instances (dynos) so that the Heroku API is only called once every N seconds – 10 by default.
16
+
17
+ - When workload demands more dynos, scale will adjust directly upward to target capacity.
18
+
19
+ - As workload diminishes, scale will adjust downward one dyno at a time. When downscaling a process, the highest numbered dyno (ex: `worker.1`, `worker.2`, etc...) will be quieted and then removed from the formation. This combines Heroku's [autoscaling logic](https://devcenter.heroku.com/articles/scaling#autoscaling-logic) with Sidekiq's [quieting strategy](https://github.com/mperham/sidekiq/wiki/Signals#tstp).
20
+
21
+ ## Gem installation
22
+
23
+ ```ruby
24
+ gem 'sidekiq'
25
+ gem 'sidekiq-heroku-autoscale'
26
+ ```
27
+
28
+ If you're not using Rails, you'll need to require `sidekiq-heroku-autoscale` after `sidekiq`.
29
+
30
+ ## Environment config
31
+
32
+ You'll need to generate a Heroku platform API token that enables your app to adjust its own dyno formation. This can be done through the Heroku CLI with:
33
+
34
+ ```shell
35
+ heroku authorizations:create
36
+ ```
37
+
38
+ Copy the `Token` value and add it along with your app's name as environment variables in your app:
39
+
40
+ ```shell
41
+ SIDEKIQ_HEROKU_AUTOSCALE_API_TOKEN=<token>
42
+ SIDEKIQ_HEROKU_AUTOSCALE_APP=<app-name>
43
+ ```
44
+
45
+ The Heroku Autoscaler plugin will automatically check for these two environment variables. You'll also find some setup suggestions in Sidekiq's [Heroku deployment](https://github.com/mperham/sidekiq/wiki/Deployment#heroku) docs. Specifically, you'll want to include the `-t 25` option in your Procfile's Sidekiq command to maximize process quietdown time:
46
+
47
+ ```shell
48
+ web: bundle exec rails start
49
+ worker: bundle exec sidekiq -t 25
50
+ ```
51
+
52
+ ## Plugin config
53
+
54
+ Add a configuration file for the Heroku Autoscale plugin. YAML works well. A simple configuration with one `worker` process (named in your Procfile) that monitors all Sidekiq queues and starts/stops in the presence of work looks like this:
55
+
56
+ **config/sidekiq_heroku_autoscale.yml**
57
+
58
+ ```yaml
59
+ app_name: test-app
60
+ processes:
61
+ worker:
62
+ system:
63
+ watch_queues: *
64
+ include_retrying: true
65
+ include_scheduled: false
66
+ scale:
67
+ mode: binary
68
+ max_dynos: 1
69
+ ```
70
+
71
+ Then, add an initializer to hand your configuration off to the plugin:
72
+
73
+ **config/initializers/sidekiq.rb**
74
+
75
+ ```ruby
76
+ config = YAML.load_file('<path/to/config.yml>')
77
+ Sidekiq::HerokuAutoscale.init(config)
78
+ ```
79
+
80
+ A more advanced configuration with multiple process types that watch specific queues would look like this – where `first` and `second` are two Heroku process types:
81
+
82
+ ```yaml
83
+ api_token: <optional - for dynamic insertion only!>
84
+ app_name: test-app
85
+ throttle: 20
86
+ history: 7200
87
+ processes:
88
+ first:
89
+ system:
90
+ watch_queues:
91
+ - default
92
+ - low
93
+ include_retrying: false
94
+ include_scheduled: false
95
+ scale:
96
+ mode: binary
97
+ max_dynos: 2
98
+ quiet_buffer: 15
99
+
100
+ second:
101
+ system:
102
+ watch_queues:
103
+ - high
104
+ include_retrying: false
105
+ include_scheduled: false
106
+ scale:
107
+ mode: linear
108
+ max_dynos: 5
109
+ workers_per_dyno: 20
110
+ min_factor: 1
111
+ ```
112
+
113
+ **Config Options**
114
+
115
+ - `api_token:` optional, same as `SIDEKIQ_HEROKU_AUTOSCALE_API_TOKEN`. Always prefer the ENV variable, or dynamically insert this.
116
+ - `app_name:` optional, same as `SIDEKIQ_HEROKU_AUTOSCALE_APP`.
117
+ - `throttle:` number of seconds to throttle between scale adjustments. The default is 10, so the Heroku API will only be hit once every ten seconds regardless of how many time the process is pinged during that timeframe. This value also dictates the tick frequency on the web UI history graph.
118
+ - `history:` number of seconds to track history in the web UI. The default is 3600, or 1 hour. The history graph renders ticks using the history duration divided by throttle time – so 3600 seconds of history on a 10 second throttle produce 360 data points. Therefore, it's best to keep these settings in modest proportions to one another. You'll probably be sad if you try to display days or weeks of history.
119
+ - `processes:` a list of Heroku process types named in your Procfile. For example, `worker` or `sidekiq`.
120
+ - `process.system.watch_queues:` a list of Sidekiq queues to watch for work, or `*` for all queues. Queue names must be mutually exclusive to avoid collisions. That means a queue name may only appear once across all processes, and that `*` (all) may not be combined with other queue names.
121
+ - `process.system.include_retrying:` specifies if the Sidekiq retry set should be included while assessing workload. Watching retries may cause undesirable levels of uptime.
122
+ - `process.system.include_scheduled:` specifies if the Sidekiq scheduled set should be included while assessing workload. Watching scheduled jobs may cause undesirable levels of idle uptime. Also, no new jobs will be scheduled unless Sidekiq is running.
123
+ - `process.scale.mode:` accepts "binary" (on/off) or "linear" (scaled to workload).
124
+ - `process.scale.max_dynos:` maximum allowed concurrent dynos. In binary mode, this will be the fixed operating capacity. In linear mode, this will be the maximum extent that dynos may scale up to.
125
+ - `process.scale.workers_per_dyno:` Linear mode only. This specifies the anticipated workforce per dyno to calculate scale around. This should generally align with Sidekiq's `concurrency` setting.
126
+ - `process.quiet_buffer:` number of seconds to quiet a dyno (stopping it from taking on new work) before downscaling its process. This buffer occurs _before_ reducing the number of dynos for a given process type. After downscale, you may configure an [additional quietdown threshold](https://github.com/mperham/sidekiq/wiki/Deployment#heroku). Note: during this quiet buffer your formation includes a decomissioned dyno, which is awkward – thus no other scale adjustments (up or down) are allowed until the quieted dyno has been dropped. Be accordingly judicious with this buffer.
127
+
128
+ ## Web UI
129
+
130
+ The web UI is an optional extension of Sidekiq's web UI. To activate it, just require `sidekiq/heroku_autoscale/web` after the base `sidekiq/web`, and then mount `Sidekiq::Web` as normal:
131
+
132
+ ```ruby
133
+ require 'sidekiq/web'
134
+ require 'sidekiq/heroku_autoscale/web'
135
+
136
+ Rails.application.routes.draw do
137
+ mount Sidekiq::Web, at: '/sidekiq'
138
+ end
139
+ ```
140
+
141
+ ## Tests
142
+
143
+ Nothing fancy...
144
+
145
+ ```bash
146
+ # start a redis server
147
+ redis-server test/redis_test.conf
148
+
149
+ # then run tests in another terminal window
150
+ bundle exec rake test
151
+ ```
152
+
153
+ ### Contributors
154
+
155
+ - Justin Love [@wondible](http://twitter.com/wondible), [https://github.com/JustinLove](https://github.com/JustinLove)
156
+ - Benjamin Kudria [https://github.com/bkudria](https://github.com/bkudria)
157
+ - claudiofullscreen [https://github.com/claudiofullscreen](https://github.com/claudiofullscreen)
158
+ - Fix Peña [https://github.com/fixr](https://github.com/fixr)
159
+ - Gabriel Givigier Guimarães [https://github.com/givigier](https://github.com/givigier)
160
+ - Matt Anderson [https://github.com/tonkapark](https://github.com/tonkapark)
161
+ - Thibaud Guillaume-Gentil [https://github.com/jilion](https://github.com/jilion)
162
+
163
+ ## Licence
164
+
165
+ Sidekiq Heroku Autoscale plugin is released under the [MIT license](https://opensource.org/licenses/MIT).
@@ -0,0 +1,2 @@
1
+ require 'sidekiq'
2
+ require 'sidekiq/heroku_autoscale'
@@ -0,0 +1,69 @@
1
+ require 'sidekiq/heroku_autoscale/heroku_app'
2
+ require 'sidekiq/heroku_autoscale/middleware'
3
+ require 'sidekiq/heroku_autoscale/poll_interval'
4
+ require 'sidekiq/heroku_autoscale/process'
5
+ require 'sidekiq/heroku_autoscale/queue_system'
6
+ require 'sidekiq/heroku_autoscale/scale_strategy'
7
+ require 'sidekiq/heroku_autoscale/version'
8
+
9
+ module Sidekiq
10
+ module HerokuAutoscale
11
+
12
+ class << self
13
+ def app
14
+ @app
15
+ end
16
+
17
+ def init(options)
18
+ options = options.transform_keys(&:to_sym)
19
+ @app = HerokuApp.new(options)
20
+
21
+ ::Sidekiq.logger.warn('Heroku platform API is not configured for Sidekiq::HerokuAutoscale') unless @app.live?
22
+
23
+ # configure sidekiq queue server
24
+ ::Sidekiq.configure_server do |config|
25
+ config.on(:startup) do
26
+ dyno_name = ENV['DYNO']
27
+ next unless dyno_name
28
+
29
+ process = @app.process_by_name(dyno_name.split('.').first)
30
+ next unless process
31
+
32
+ process.ping!
33
+ end
34
+
35
+ config.server_middleware do |chain|
36
+ chain.add(Middleware, @app)
37
+ end
38
+
39
+ # for jobs that queue other jobs...
40
+ config.client_middleware do |chain|
41
+ chain.add(Middleware, @app)
42
+ end
43
+ end
44
+
45
+ # configure sidekiq app client
46
+ ::Sidekiq.configure_client do |config|
47
+ config.client_middleware do |chain|
48
+ chain.add(Middleware, @app)
49
+ end
50
+ end
51
+
52
+ # immedaitely wake all processes during client launch
53
+ @app.ping! unless ::Sidekiq.server?
54
+
55
+ @app
56
+ end
57
+
58
+ def exception_handler
59
+ @exception_handler ||= lambda { |ex|
60
+ p ex
61
+ puts ex.backtrace
62
+ }
63
+ end
64
+
65
+ attr_writer :exception_handler
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,144 @@
1
+ require 'platform-api'
2
+
3
+ module Sidekiq
4
+ module HerokuAutoscale
5
+
6
+ class HerokuApp
7
+ attr_reader :app_name, :throttle, :history
8
+
9
+ # Builds process managers based on configuration (presumably loaded from YAML)
10
+ def initialize(config)
11
+ config = JSON.parse(JSON.generate(config), symbolize_names: true)
12
+
13
+ api_token = config[:api_token] || ENV['SIDEKIQ_HEROKU_AUTOSCALE_API_TOKEN']
14
+ @app_name = config[:app_name] || ENV['SIDEKIQ_HEROKU_AUTOSCALE_APP']
15
+ @throttle = config[:throttle] || 10
16
+ @history = config[:history] || 60 * 60 # 1 hour
17
+ @client = api_token ? PlatformAPI.connect_oauth(api_token) : nil
18
+
19
+ @processes_by_name = {}
20
+ @processes_by_queue = {}
21
+
22
+ config[:processes].each_pair do |name, opts|
23
+ process = Process.new(
24
+ app_name: @app_name,
25
+ name: name,
26
+ client: @client,
27
+ throttle: @throttle,
28
+ history: @history,
29
+ **opts.slice(:system, :scale, :quiet_buffer)
30
+ )
31
+ @processes_by_name[name.to_s] = process
32
+
33
+ process.queue_system.watch_queues.each do |queue_name|
34
+ # a queue may only be managed by a single heroku process type (to avoid scaling conflicts)
35
+ # thus, raise an error over duplicate queue names or when "*" isn't exclusive
36
+ if @processes_by_queue.key?(queue_name) || @processes_by_queue.key?('*') || (queue_name == '*' && @processes_by_queue.keys.any?)
37
+ raise ArgumentError, 'watched queues must be exclusive to a single Heroku process type'
38
+ end
39
+ @processes_by_queue[queue_name] = process
40
+ end
41
+ end
42
+ end
43
+
44
+ # checks if there's a live Heroku client setup
45
+ def live?
46
+ !!@client
47
+ end
48
+
49
+ # pings all processes in the application
50
+ # useful for requesting live updates
51
+ def ping!
52
+ processes.each(&:ping!)
53
+ end
54
+
55
+ def processes
56
+ @processes ||= @processes_by_name.values
57
+ end
58
+
59
+ def process_names
60
+ @process_names ||= @processes_by_name.keys
61
+ end
62
+
63
+ def queue_names
64
+ @queue_names ||= @processes_by_queue.keys
65
+ end
66
+
67
+ def process_by_name(process_name)
68
+ @processes_by_name[process_name]
69
+ end
70
+
71
+ def process_for_queue(queue_name)
72
+ @processes_by_queue[queue_name] || @processes_by_queue['*']
73
+ end
74
+
75
+ def stats
76
+ histories = history_stats
77
+ processes.each_with_object({}) do |process, memo|
78
+ memo[process.name] = {
79
+ dynos: process.dynos,
80
+ status: process.status,
81
+ updated: process.updated_at.to_s,
82
+ history: histories[process.name],
83
+ }
84
+ end
85
+ end
86
+
87
+ def history_stats(now=Time.now.utc)
88
+ # calculate a series time to anchor graph ticks on
89
+ # the series snaps to thresholds of N (throttle duration)
90
+ series_time = (now.to_f / @throttle).floor * @throttle
91
+ num_ticks = (@history / @throttle).floor
92
+ first_tick = series_time - @throttle * num_ticks
93
+
94
+ # all ticks is a hash of timestamp keys to plot
95
+ all_ticks = Array.new(num_ticks)
96
+ .each_with_index.map { |v, i| (first_tick + @throttle * i).to_s }
97
+ .each_with_object({}) { |tick, memo| memo[tick] = nil }
98
+
99
+ # get current and previous history collections for each process
100
+ # history pages snap to thresholds of M (history duration)
101
+ current_page = (now.to_f / @history).floor * @history
102
+ previous_page = current_page - @history
103
+ history_pages = ::Sidekiq.redis do |c|
104
+ c.pipelined do
105
+ processes.each do |process|
106
+ c.hgetall("#{ process.cache_key }:#{ previous_page }")
107
+ c.hgetall("#{ process.cache_key }:#{ current_page }")
108
+ end
109
+ end
110
+ end
111
+
112
+ history_by_process = {}
113
+ history_pages.each_slice(2).each_with_index do |(a, b), i|
114
+ process = processes[i]
115
+
116
+ # flatten all history pages into a single collection
117
+ ticks = all_ticks
118
+ .merge(a.merge!(b))
119
+ .map { |k, v| [k.to_i, v ? v.to_i : nil] }
120
+ .sort_by { |tick| tick[0] }
121
+
122
+ # separate the older stats from the current history timeframe
123
+ past_ticks, present_ticks = ticks.partition { |tick| tick[0] < first_tick }
124
+
125
+ # select a running value starting point
126
+ # run from the end of past history, or beginning of present history, or current dynos
127
+ value = past_ticks.last || present_ticks.detect { |tick| !!tick[1] }
128
+ value = value ? value[1] : process.dynos
129
+
130
+ # assign a running value across all ticks
131
+ present_ticks.each do |tick|
132
+ tick[1] ||= value
133
+ value = tick[1]
134
+ end
135
+
136
+ history_by_process[process.name] = present_ticks
137
+ end
138
+
139
+ history_by_process
140
+ end
141
+ end
142
+
143
+ end
144
+ end
@@ -0,0 +1,21 @@
1
+ module Sidekiq
2
+ module HerokuAutoscale
3
+
4
+ class Middleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(worker_class, item, queue, _=nil)
10
+ result = yield
11
+
12
+ if process = @app.process_for_queue(queue)
13
+ process.ping!
14
+ end
15
+
16
+ result
17
+ end
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,34 @@
1
+ module Sidekiq
2
+ module HerokuAutoscale
3
+
4
+ class PollInterval
5
+ def initialize(method_name, before_update: 0, after_update: 0)
6
+ @method_name = method_name
7
+ @before_update = before_update
8
+ @after_update = after_update
9
+ @requests = {}
10
+ end
11
+
12
+ def call(process)
13
+ return unless process
14
+ @requests[process.name] ||= process
15
+ poll!
16
+ end
17
+
18
+ def poll!
19
+ @thread ||= Thread.new do
20
+ begin
21
+ while @requests.size > 0
22
+ sleep(@before_update) if @before_update > 0
23
+ @requests.reject! { |n, p| p.send(@method_name) }
24
+ sleep(@after_update) if @after_update > 0
25
+ end
26
+ ensure
27
+ @thread = nil
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ end
34
+ end