sidekiq-qlimit 0.0.4

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: d62aada6eb22eb9c8e3c23a875d538fcabf8613d
4
+ data.tar.gz: dcac1cc3a9a1e4d98b19ffebf7c717992a2c5863
5
+ SHA512:
6
+ metadata.gz: dff8269278b27266ac7b846042178d49fd53d089c0022ef623bd2100e5e46c941d926380ef307dcf0e19af5ee258407bb80b3f4dc2a9e045813c62bc58478a44
7
+ data.tar.gz: 81f1586e34d2697ed9ae2575e70d0891aea34f88d2c3bcd4fd3ad9095a6f4245f88f0bf0dc5b44ab4e26744c7d0e651acbf410c97f74c16bef310848f08889a7
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "celluloid" if Sidekiq::VERSION < "4.0.0"
4
+ require "sidekiq"
5
+ require "sidekiq/fetch"
6
+
7
+ module Sidekiq
8
+ module Qlimit
9
+ ##
10
+ # Throttled version of `Sidekiq::QlimitFetch` fetcher strategy.
11
+ #
12
+ #
13
+ # Just add somewhere in your bootstrap:
14
+ #
15
+ # require "sidekiq/qlimit"
16
+ # Sidekiq::Qlimit.setup!
17
+ #
18
+ # Establish max # of total workers per queue
19
+ #
20
+ # sidekiq.yml
21
+ # --
22
+ #
23
+ # :qlimit:
24
+ # queue_name_1: 2
25
+ # queue_name_2: 4
26
+ #
27
+ #--
28
+ # TODO: Store current limits in redis and read from redis to display
29
+ #++
30
+ class QlimitFetch < ::Sidekiq::BasicFetch
31
+
32
+ # Redis Script SHA tracking
33
+ @@qlimit_increment_sha = ""
34
+ @@qlimit_decrement_sha = ""
35
+
36
+ # Qlimit aware UnitOfWork
37
+ UnitOfWork = Struct.new(:queue, :job) do
38
+ def acknowledge
39
+ # Reduce qlimit on acknowledge
40
+ QlimitFetch.qlimit_decrement(queue_name)
41
+ end
42
+
43
+ def queue_name
44
+ queue.sub(/.*queue:/, ''.freeze)
45
+ end
46
+
47
+ def requeue
48
+ Sidekiq.redis do |conn|
49
+ conn.rpush("queue:#{queue_name}", job)
50
+ end
51
+ end
52
+ end
53
+
54
+ ##
55
+ # Modified Initialize Function - Reads :qlimit from config source such as sidekiq.yml
56
+ def initialize(options)
57
+ super(options)
58
+
59
+ # puts options
60
+ # {:queues=>["unlimited", "limited"], :labels=>[], :concurrency=>5, :require=>".", :environment=>nil, :timeout=>8, :poll_interval_average=>nil, :average_scheduled_poll_interval=>15, :error_handlers=>[#<Sidekiq::ExceptionHandler::Logger:0x007fda0c30fe38>], :lifecycle_events=>{:startup=>[], :quiet=>[], :shutdown=>[]}, :dead_max_jobs=>10000, :dead_timeout_in_seconds=>15552000, :pidfile=>"tmp/pids/sidekiq.pid", :qlimit=>[{"limited"=>2}, {"fake"=>3}], :config_file=>"config/sidekiq.yml", :strict=>true, :fetch=>Sidekiq::Qlimit::QlimitFetch, :tag=>"example"}
61
+
62
+
63
+ # Get our limits
64
+ @per_queue_limits = {}
65
+ unless options[:qlimit].nil?
66
+ options[:qlimit].each do |limit_hash|
67
+ limit_hash.each do |k, v|
68
+ @per_queue_limits[k] = v
69
+ end
70
+ end
71
+ end
72
+
73
+ # TODO: Store current limits in redis and read from redis to display
74
+
75
+ QlimitFetch.qlimit_script_load
76
+ end
77
+
78
+
79
+
80
+
81
+
82
+ ##
83
+ # Returns an array of queue names that are NOT too busy
84
+ def qualifying_queues
85
+ # Working copy of @queues list
86
+ allowed_queues = @queues.dup
87
+
88
+ # Remove any queue which has hit the maximum number of concurrent jobs
89
+ # NOTE: Could remove a queue for which a job has just finished, but we'll catch that job on the next loop
90
+ @per_queue_limits.each do |k, v|
91
+ Sidekiq.logger.debug("Checking #{k} => #{v}")
92
+ Sidekiq.redis do |conn|
93
+ jobs_in_queue = QlimitFetch.qlimit_get(k)
94
+ if jobs_in_queue.to_i >= v # detected a maximum concurrent case
95
+ allowed_queues.delete("queue:#{k}")
96
+ Sidekiq.logger.debug("Remove: queue:#{k}")
97
+ end
98
+ end
99
+
100
+ end
101
+
102
+ Sidekiq.logger.debug("Allowed Queues: #{allowed_queues}")
103
+
104
+ # Follow original sidekiq fetch strategy
105
+ if @strictly_ordered_queues
106
+ allowed_queues
107
+ else
108
+ allowed_queues = allowed_queues.shuffle.uniq
109
+ allowed_queues << TIMEOUT # brpop should Wait X number of seconds before returning nil
110
+ allowed_queues
111
+ end
112
+ end
113
+
114
+
115
+ ##
116
+ # Returns a "UnitOfWork" from a qualifying queue if available
117
+ def retrieve_work
118
+ work_text = Sidekiq.redis { |conn| conn.brpop(*qualifying_queues) }
119
+ work = UnitOfWork.new(*work_text) if work_text
120
+
121
+ if work.nil?
122
+ Sidekiq.logger.debug("No Work")
123
+ return
124
+ end
125
+
126
+ # We hack around the simultaneous zero starting problem by incrementing an expiring counter
127
+ okay_to_continue = QlimitFetch.qlimit_increment(work.queue_name, @per_queue_limits[work.queue_name])
128
+ Sidekiq.logger.debug("QlimitIncrement Result => #{okay_to_continue}")
129
+
130
+ if okay_to_continue
131
+ Sidekiq.logger.debug("Perform #{work}")
132
+ return work
133
+ else
134
+ Sidekiq.logger.debug("Requeue #{work}")
135
+ Sidekiq.redis { |conn| conn.lpush(work.queue, work.job) }
136
+ return nil
137
+ end
138
+ end
139
+
140
+
141
+ ##
142
+ # Returns a "UnitOfWork" from a qualifying queue if available
143
+ def self.qlimit_script_load
144
+ # Note:
145
+ # This is not theadsafe. Instead we *blindly* increment if current < max.
146
+ # We assume there is little or no penalty for running a few too many workers
147
+ qlimit_increment_script = <<-EOF
148
+ local max = tonumber(ARGV[1])
149
+ local current = tonumber(redis.call('get',KEYS[1]))
150
+ local current_i = 0
151
+ if nil == current then
152
+ current_i = 0
153
+ else
154
+ current_i = tonumber(current)
155
+ end
156
+
157
+ if current_i < max then
158
+ redis.call('incr',KEYS[1])
159
+ redis.call('expire',KEYS[1], 14400)
160
+ return true
161
+ else
162
+ return false
163
+ end
164
+ EOF
165
+
166
+ # Note:
167
+ # If we zero a counter or reduce a limit, we could go "negative" on a decrement.
168
+ # Limit minimum at 0 which is self correcting.
169
+ qlimit_decrement_script = <<-EOF
170
+ redis.call('decrby', KEYS[1], ARGV[1])
171
+ local current = tonumber(redis.call('get',KEYS[1]))
172
+ if current < 0 then
173
+ redis.call('set',KEYS[1], 0)
174
+ end
175
+ redis.call('expire',KEYS[1], 14400)
176
+ EOF
177
+
178
+ Sidekiq.redis do |conn|
179
+ @@qlimit_increment_sha = conn.script(:load, qlimit_increment_script)
180
+ @@qlimit_decrement_sha = conn.script(:load, qlimit_decrement_script)
181
+ end
182
+ end
183
+
184
+ ##
185
+ # Returns a hash of current count of running jobs in queues
186
+ #
187
+ # Example: { "queue1": 123, "queue2": 456 }
188
+ def self.qlimit_hash
189
+ qlimits = {}
190
+ Sidekiq.redis do |conn|
191
+ conn.scan_each(match: "qlimit:*") do |key|
192
+ qkey = key.sub(/.*qlimit:/, ''.freeze)
193
+ qvalue = conn.get(key)
194
+ qvalue ||= 0
195
+ qlimits[qkey] ||= qvalue.to_i
196
+ end
197
+ end
198
+ qlimits
199
+ end
200
+
201
+ ##
202
+ # Increment current count of running jobs in queue by amount NOT to exceed limit
203
+ #
204
+ # return 1 if incrementing count would NOT exceed limit
205
+ # return 0 if incrementing count would exceed limit
206
+ def self.qlimit_increment(queue, limit)
207
+ return 1 if limit.nil? # No limit, no processing
208
+
209
+ Sidekiq.redis do |conn|
210
+ result = conn.evalsha(@@qlimit_increment_sha,["qlimit:#{queue}"],[limit])
211
+ #Sidekiq.logger.debug("Checking Qlimit #{queue} => #{result}")
212
+ return result
213
+ end
214
+ end
215
+
216
+ ##
217
+ # Decrement current count of running jobs in queue by amount (default: 1)
218
+ def self.qlimit_decrement(queue, amount = 1)
219
+ Sidekiq.logger.debug("Qlimit Decrement: #{queue} by #{amount}")
220
+
221
+ Sidekiq.redis do |conn|
222
+ result = conn.evalsha(@@qlimit_decrement_sha,["qlimit:#{queue}"], [amount])
223
+ end
224
+ end
225
+
226
+ ##
227
+ # Set current count of running jobs in queue to 0
228
+ def self.qlimit_reset(queue)
229
+ self.qlimit_set(queue, 0)
230
+ end
231
+
232
+ ##
233
+ # Set current count of running jobs in queue
234
+ def self.qlimit_set(queue, amount = 0)
235
+ Sidekiq.logger.debug("Qlimit Set: #{queue} => #{amount}")
236
+ Sidekiq.redis do |conn|
237
+ conn.set("qlimit:#{queue}", amount)
238
+ conn.expire("qlimit:#{queue}", 14400)
239
+ end
240
+ end
241
+
242
+ ##
243
+ # Get current count of running jobs from queue
244
+ def self.qlimit_get(queue)
245
+ Sidekiq.redis do |conn|
246
+ result = conn.get("qlimit:#{queue}")
247
+ return result.to_i unless result.nil?
248
+ return 0
249
+ end
250
+ end
251
+
252
+ ##
253
+ # Used to requeue jobs on sidekiq shutdown/termination
254
+ def self.bulk_requeue(inprogress, options)
255
+ return if inprogress.empty?
256
+
257
+ Sidekiq.logger.debug { "Re-queueing terminated jobs" }
258
+ jobs_to_requeue = {}
259
+ inprogress.each do |unit_of_work|
260
+ jobs_to_requeue[unit_of_work.queue_name] ||= []
261
+ jobs_to_requeue[unit_of_work.queue_name] << unit_of_work.job
262
+ end
263
+
264
+ Sidekiq.redis do |conn|
265
+ conn.pipelined do
266
+ jobs_to_requeue.each do |queue, jobs|
267
+ conn.rpush("queue:#{queue}", jobs)
268
+ # Reduce qlimit on requeue of amount: jobs.length
269
+ self.qlimit_decrement(queue, jobs.length)
270
+ end
271
+ end
272
+ end
273
+ Sidekiq.logger.debug("Pushed #{inprogress.size} jobs back to Redis")
274
+ rescue => ex
275
+ Sidekiq.logger.warn("Failed to requeue #{inprogress.size} jobs: #{ex.message}")
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Qlimit
5
+ # Gem version
6
+ VERSION = "0.0.4".freeze
7
+ end
8
+ end
@@ -0,0 +1,39 @@
1
+ <header class='row'>
2
+ <div class='span5 col-sm-5'>
3
+ <h3><%=t 'Qlimits' %></h3>
4
+ </div>
5
+ </header>
6
+
7
+ <table class="table table-hover table-bordered table-striped table-white">
8
+ <thead>
9
+ <tr>
10
+ <th>Queue</th>
11
+ <th style="text-align:center;">Current Running</th>
12
+ <th style="text-align:center;">Limit</th>
13
+ <th style="text-align:center;">Zero</th>
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ <% Sidekiq::Qlimit::QlimitFetch.qlimit_hash.each do |k,v| %>
18
+ <tr>
19
+ <td style="vertical-align:middle;text-align:center;">
20
+ <%= k %>
21
+ </td>
22
+ <td style="vertical-align:middle;text-align:center;">
23
+ <%= v %>
24
+ </td>
25
+ <td style="vertical-align:middle;text-align:center;">
26
+ -Later-
27
+ <!-- TODO: Store current limits in redis and read from redis to display -->
28
+ </td>
29
+ <td style="vertical-align:middle;text-align:center;">
30
+ <form action="<%= root_path %>qlimit/<%= k %>" method="POST">
31
+ <%= csrf_tag %>
32
+ <input type="hidden" name="_method" value="delete" />
33
+ <button class="btn btn-danger" type="submit">Reset</button>
34
+ </form>
35
+ </td>
36
+ </tr>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ # stdlib
3
+ require "pathname"
4
+
5
+ # 3rd party
6
+ require "sidekiq"
7
+ require "sidekiq/web"
8
+
9
+ # internal
10
+ require "sidekiq/qlimit/web_extension"
11
+
12
+
13
+ if defined?(Sidekiq::Web)
14
+ Sidekiq::Web.register Sidekiq::Qlimit::WebExtension
15
+
16
+ if Sidekiq::Web.tabs.is_a?(Array)
17
+ # For sidekiq < 2.5
18
+ Sidekiq::Web.tabs << "cron"
19
+ else
20
+ Sidekiq::Web.tabs["Qlimit"] = "qlimit"
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ require 'tilt/erubis'
2
+
3
+ module Sidekiq
4
+ module Qlimit
5
+ module WebExtension
6
+ def self.registered(app)
7
+ view_path = File.join(File.expand_path("..", __FILE__), "views")
8
+ app.get "/qlimit" do
9
+ render(:erb, File.read(File.join(view_path, "index.html.erb")))
10
+ end
11
+
12
+ app.delete "/qlimit/:id" do |id|
13
+ Sidekiq::Qlimit::QlimitFetch.qlimit_reset(id)
14
+ redirect "#{root_path}qlimit"
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+ # 3rd party
3
+ require "sidekiq"
4
+
5
+ # internal
6
+ require "sidekiq/version"
7
+ require_relative "qlimit/qlimit_fetch"
8
+ require_relative 'qlimit/web'
9
+
10
+
11
+ # @see https://github.com/mperham/sidekiq/
12
+ module Sidekiq
13
+ # Sidekiq per queue 'soft' limiting
14
+ #
15
+ # Just add somewhere in your bootstrap (config/initializers/sidekiq-qlimit.rb):
16
+ #
17
+ # require "sidekiq/qlimit"
18
+ # Sidekiq::Qlimit.setup!
19
+ #
20
+ module Qlimit
21
+ class << self
22
+ # Hooks Qlimit into sidekiq.
23
+ # @return [void]
24
+ def setup!
25
+ Sidekiq.configure_server do |config|
26
+ require "sidekiq/qlimit/qlimit_fetch"
27
+ Sidekiq.options[:fetch] = Sidekiq::Qlimit::QlimitFetch
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sidekiq-qlimit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.4
5
+ platform: ruby
6
+ authors:
7
+ - Jeff Chan
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-05-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sidekiq
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: tilt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sinatra
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 1.4.7
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 1.4.7
55
+ - !ruby/object:Gem::Dependency
56
+ name: bundler
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.10'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.10'
69
+ description: Sidekiq per queue 'soft' limiting. It ain't perfect, but it's enough.
70
+ email:
71
+ - jeff@braincommerce.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - lib/sidekiq/qlimit.rb
77
+ - lib/sidekiq/qlimit/qlimit_fetch.rb
78
+ - lib/sidekiq/qlimit/version.rb
79
+ - lib/sidekiq/qlimit/views/index.html.erb
80
+ - lib/sidekiq/qlimit/web.rb
81
+ - lib/sidekiq/qlimit/web_extension.rb
82
+ homepage: https://github.com/braincom/sidekiq-qlimit
83
+ licenses:
84
+ - MIT
85
+ metadata: {}
86
+ post_install_message:
87
+ rdoc_options:
88
+ - "--title"
89
+ - Sidekiq-Qlimit - A Soft Limiter
90
+ - "--main"
91
+ - README.md
92
+ - "--exclude"
93
+ - example
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubyforge_project:
108
+ rubygems_version: 2.5.2
109
+ signing_key:
110
+ specification_version: 4
111
+ summary: Sidekiq per queue 'soft' limiting.
112
+ test_files: []