sidekiq-priority_queue 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 23819a77a9986da48fe0e15a590bd948abf62cafa7d48f7ad8aa2d6129dbb476
4
+ data.tar.gz: cc8e7a8a99c095237a6b44c0a266753d98707c724e5af70b494a71b3a25476ac
5
+ SHA512:
6
+ metadata.gz: '0884b842b53184df96c7e753d0d8d6b04621b2b5810a33e5798e95d76b13e09776ac4db62c520c0e822be0987b798deaa473e76898ecdd92b4095230b6b65501'
7
+ data.tar.gz: f1bb0dfb7a3048ce7ca80924916ac6817ed16262a2d98596f1da50db35292b2a2cb3287f0fcdb61a468f21f39d0dac5de3f8989b7bdc87515f728179fbb7576a
@@ -0,0 +1,29 @@
1
+ name: Tests
2
+ on:
3
+ pull_request:
4
+ push:
5
+ branches:
6
+ - main
7
+ jobs:
8
+ tests:
9
+ name: Run tests
10
+ outputs:
11
+ job-status: ${{ job.status }}
12
+ runs-on: ubuntu-18.04
13
+ timeout-minutes: 10
14
+ services:
15
+ redis:
16
+ image: redis:alpine
17
+ ports:
18
+ - 6379:6379
19
+ steps:
20
+ - name: Checkout
21
+ uses: actions/checkout@v2.3.1
22
+ - name: Set up correct version of Ruby
23
+ uses: actions/setup-ruby@v1
24
+ with:
25
+ ruby-version: 2.7
26
+ - name: Install dependencies via Bundler
27
+ run: bundle install --jobs 4 --retry 3
28
+ - name: Run tests
29
+ run: bundle exec rake
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ .vagrant
2
+ /.idea/
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+
5
+ group :test do
6
+ gem 'pry-byebug'
7
+ gem 'minitest'
8
+ gem 'rake'
9
+ gem 'simplecov'
10
+ gem 'rack-test'
11
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,50 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sidekiq-priority_queue (1.0.2)
5
+ sidekiq (>= 6)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ byebug (11.1.3)
11
+ coderay (1.1.3)
12
+ connection_pool (2.2.3)
13
+ docile (1.3.4)
14
+ method_source (1.0.0)
15
+ minitest (5.14.3)
16
+ pry (0.13.1)
17
+ coderay (~> 1.1)
18
+ method_source (~> 1.0)
19
+ pry-byebug (3.9.0)
20
+ byebug (~> 11.0)
21
+ pry (~> 0.13.0)
22
+ rack (2.2.3)
23
+ rack-test (1.1.0)
24
+ rack (>= 1.0, < 3)
25
+ rake (13.0.3)
26
+ redis (4.2.5)
27
+ sidekiq (6.1.3)
28
+ connection_pool (>= 2.2.2)
29
+ rack (~> 2.0)
30
+ redis (>= 4.2.0)
31
+ simplecov (0.21.2)
32
+ docile (~> 1.1)
33
+ simplecov-html (~> 0.11)
34
+ simplecov_json_formatter (~> 0.1)
35
+ simplecov-html (0.12.3)
36
+ simplecov_json_formatter (0.1.2)
37
+
38
+ PLATFORMS
39
+ ruby
40
+
41
+ DEPENDENCIES
42
+ minitest
43
+ pry-byebug
44
+ rack-test
45
+ rake
46
+ sidekiq-priority_queue!
47
+ simplecov
48
+
49
+ BUNDLED WITH
50
+ 2.1.4
data/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Copyright (c) ChartMogul Ltd
2
+
3
+ Sidekiq-priority_queue is an Open Source project licensed under the terms of
4
+ the LGPLv3 license. Please see <http://www.gnu.org/licenses/lgpl-3.0.html>
5
+ for license text.
data/README.md ADDED
@@ -0,0 +1,64 @@
1
+ Sidekiq Priority Queue
2
+ ==============
3
+ Extends Sidekiq with support for queuing jobs with a fine grained priority and emulating multiple queues using a single Redis sorted set, ideal for multi-tenant applications.
4
+
5
+ The standard Sidekiq setup performs really well using Redis lists, but lists can only be strict FIFO queues, which can be hugely problematic when they process slowly and one user may need to wait hours behind a backlog of jobs.
6
+
7
+ Sidekiq Priority Queue offers a plug-in solution retaining the simplicity and performance of Sidekiq. The priority queue is a building block for emulating sub-queues (per tenant or user) by de-prioritising jobs according to how many jobs are already in this sub-queue.
8
+
9
+ Sources of inspiration are naturally Sidekiq itself, the fantastic Redis documentation, and https://github.com/gocraft/work
10
+
11
+ Installation
12
+ -----------------
13
+
14
+ gem install sidekiq-priority_queue
15
+
16
+ Configuration
17
+ -----------------
18
+ ```
19
+ Sidekiq.configure_server do |config|
20
+ config.options[:fetch] = Sidekiq::PriorityQueue::Fetch
21
+ end
22
+
23
+ Sidekiq.configure_client do |config|
24
+ config.client_middleware do |chain|
25
+ chain.add Sidekiq::PriorityQueue::Client
26
+ end
27
+ end
28
+ ```
29
+ Usage
30
+ -----------------
31
+ ```
32
+ class Worker
33
+ include Sidekiq::Worker
34
+ sidekiq_options priority: 1000
35
+ end
36
+ ```
37
+ Alternatively, you can split jobs into subqueues (via a proc) which are deprioritised based on the subqueue size:
38
+ ```
39
+ class Worker
40
+ include Sidekiq::Worker
41
+
42
+ # args[0] will take the `user_id` argument below, and assign priority dynamically.
43
+ sidekiq_options subqueue: ->(args){ args[0] }
44
+
45
+ def perform(user_id, other_args)
46
+ # do jobs
47
+ end
48
+ end
49
+ ```
50
+
51
+ Testing
52
+ -----------------
53
+ For example in your `spec/rails_helper.rb` you can include this line:
54
+ ```ruby
55
+ require 'sidekiq/priority_queue/testing'
56
+ ```
57
+ next to the call to `Sidekiq::Testing.inline!`. It disables the feature for testing and falls back to `inline`/`fake` modes.
58
+ If you accidentally require this in production code, it will likewise fall back to normal Sidekiq scheduling.
59
+
60
+ Development
61
+ -----------------
62
+ - Run `docker-compose up -d` to start up a temporary redis instance.
63
+ - Run `bundle install` to install dependencies.
64
+ - Run the tests with `bundle exec rake`
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+ Rake::TestTask.new(:test) do |test|
4
+ test.warning = true
5
+ test.pattern = 'test/**/test_*.rb'
6
+ end
7
+
8
+ task :default => :test
data/bin/sidekiqload ADDED
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Copyright (c) Contributed Systems LLC, ChartMogul Ltd
4
+
5
+ # Sidekiq-priority_queue is an Open Source project licensed under the terms of
6
+ # the LGPLv3 license. Please see <http://www.gnu.org/licenses/lgpl-3.0.html>
7
+ # for license text.
8
+
9
+ # Quiet some warnings we see when running in warning mode:
10
+ # RUBYOPT=-w bundle exec sidekiq
11
+ $TESTING = false
12
+
13
+ #require 'ruby-prof'
14
+ Bundler.require(:default)
15
+
16
+ require 'sidekiq/cli'
17
+ require 'sidekiq/launcher'
18
+ require 'sidekiq/priority_queue'
19
+
20
+ include Sidekiq::Util
21
+
22
+ Sidekiq.configure_server do |config|
23
+ #config.options[:concurrency] = 1
24
+ config.redis = { db: 13 }
25
+ config.options[:queues] << 'default'
26
+ config.logger.level = Logger::ERROR
27
+ config.average_scheduled_poll_interval = 2
28
+ config.options[:fetch] = Sidekiq::PriorityQueue::Fetch
29
+ end
30
+
31
+ Sidekiq.configure_client do |config|
32
+ config.client_middleware do |chain|
33
+ chain.add Sidekiq::PriorityQueue::Client
34
+ end
35
+ end
36
+
37
+ class LoadWorker
38
+ include Sidekiq::Worker
39
+ sidekiq_options retry: 1, subqueue: ->(args){ args[0] }
40
+ sidekiq_retry_in do |x|
41
+ 1
42
+ end
43
+
44
+ def perform(idx)
45
+ #raise idx.to_s if idx % 100 == 1
46
+ end
47
+ end
48
+
49
+ # brew tap shopify/shopify
50
+ # brew install toxiproxy
51
+ # gem install toxiproxy
52
+ #require 'toxiproxy'
53
+ # simulate a non-localhost network for realer-world conditions.
54
+ # adding 1ms of network latency has an ENORMOUS impact on benchmarks
55
+ #Toxiproxy.populate([{
56
+ #"name": "redis",
57
+ #"listen": "127.0.0.1:6380",
58
+ #"upstream": "127.0.0.1:6379"
59
+ #}])
60
+
61
+ self_read, self_write = IO.pipe
62
+ %w(INT TERM TSTP TTIN).each do |sig|
63
+ begin
64
+ trap sig do
65
+ self_write.puts(sig)
66
+ end
67
+ rescue ArgumentError
68
+ puts "Signal #{sig} not supported"
69
+ end
70
+ end
71
+
72
+ Sidekiq.redis {|c| c.flushdb}
73
+ def handle_signal(launcher, sig)
74
+ Sidekiq.logger.debug "Got #{sig} signal"
75
+ case sig
76
+ when 'INT'
77
+ # Handle Ctrl-C in JRuby like MRI
78
+ # http://jira.codehaus.org/browse/JRUBY-4637
79
+ raise Interrupt
80
+ when 'TERM'
81
+ # Heroku sends TERM and then waits 10 seconds for process to exit.
82
+ raise Interrupt
83
+ when 'TSTP'
84
+ Sidekiq.logger.info "Received TSTP, no longer accepting new work"
85
+ launcher.quiet
86
+ when 'TTIN'
87
+ Thread.list.each do |thread|
88
+ Sidekiq.logger.warn "Thread TID-#{(thread.object_id ^ ::Process.pid).to_s(36)} #{thread['label']}"
89
+ if thread.backtrace
90
+ Sidekiq.logger.warn thread.backtrace.join("\n")
91
+ else
92
+ Sidekiq.logger.warn "<no backtrace available>"
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ def Process.rss
99
+ `ps -o rss= -p #{Process.pid}`.chomp.to_i
100
+ end
101
+
102
+
103
+ Sidekiq.redis do |con|
104
+ count = 100_000
105
+
106
+ count.times do |idx|
107
+ #TODO why does Sidekiq::Client.push not work as expected?
108
+ con.zadd('priority-queue:default', idx, { 'class' => LoadWorker, 'args' => [idx] }.to_json)
109
+ end
110
+ end
111
+ Sidekiq.logger.error "Created #{ Sidekiq::PriorityQueue::Queue.new().size } jobs"
112
+
113
+ Monitoring = Thread.new do
114
+ watchdog("monitor thread") do
115
+ while true
116
+ sleep 1
117
+ qsize, retries = Sidekiq.redis do |conn|
118
+ conn.pipelined do
119
+ conn.zcard "priority-queue:default"
120
+ conn.zcard "retry"
121
+ end
122
+ end.map(&:to_i)
123
+ total = qsize + retries
124
+ #GC.start
125
+ Sidekiq.logger.error("RSS: #{Process.rss} Pending: #{total}")
126
+ if total == 0
127
+ Sidekiq.logger.error("Done")
128
+ exit(0)
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ begin
135
+ #RubyProf::exclude_threads = [ Monitoring ]
136
+ #RubyProf.start
137
+ fire_event(:startup)
138
+ #Sidekiq.logger.error "Simulating 1ms of latency between Sidekiq and redis"
139
+ #Toxiproxy[:redis].downstream(:latency, latency: 1).apply do
140
+ launcher = Sidekiq::Launcher.new(Sidekiq.options)
141
+ launcher.run
142
+
143
+ while readable_io = IO.select([self_read])
144
+ signal = readable_io.first[0].gets.strip
145
+ handle_signal(launcher, signal)
146
+ end
147
+ #end
148
+ rescue SystemExit => e
149
+ #Sidekiq.logger.error("Profiling...")
150
+ #result = RubyProf.stop
151
+ #printer = RubyProf::GraphHtmlPrinter.new(result)
152
+ #printer.print(File.new("output.html", "w"), :min_percent => 1)
153
+ # normal
154
+ rescue => e
155
+ raise e if $DEBUG
156
+ STDERR.puts e.message
157
+ STDERR.puts e.backtrace.join("\n")
158
+ exit 1
159
+ end
@@ -0,0 +1,7 @@
1
+ ---
2
+ version: '3.3'
3
+ services:
4
+ redis:
5
+ image: redis:alpine
6
+ ports:
7
+ - 6379:6379
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sidekiq'
4
+ require 'sidekiq/priority_queue/api'
5
+ require 'sidekiq/priority_queue/client'
6
+ require 'sidekiq/priority_queue/combined_fetch'
7
+ require 'sidekiq/priority_queue/fetch'
8
+ require 'sidekiq/priority_queue/reliable_fetch'
9
+ require 'sidekiq/priority_queue/scripts'
10
+ require 'sidekiq/priority_queue/web'
11
+
12
+ module Sidekiq
13
+ module PriorityQueue
14
+ end
15
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+ require 'sidekiq/api'
3
+
4
+
5
+ module Sidekiq
6
+ module PriorityQueue
7
+ class Queue
8
+ include Enumerable
9
+
10
+ attr_reader :name
11
+
12
+ def initialize(name='default')
13
+ @name = name
14
+ @rname = "priority-queue:#{name}"
15
+ end
16
+
17
+ def size
18
+ Sidekiq.redis { |con| con.zcard(@rname) }
19
+ end
20
+
21
+ def each
22
+ initial_size = size
23
+ deleted_size = 0
24
+ page = 0
25
+ page_size = 50
26
+
27
+ while true do
28
+ range_start = page * page_size - deleted_size
29
+ range_end = range_start + page_size - 1
30
+ entries = Sidekiq.redis do |conn|
31
+ conn.zrange @rname, range_start, range_end, withscores: true
32
+ end
33
+ break if entries.empty?
34
+ page += 1
35
+ entries.each do |entry, priority|
36
+ yield Job.new(entry, @name, priority)
37
+ end
38
+ deleted_size = initial_size - size
39
+ end
40
+ end
41
+
42
+ def self.all
43
+ Sidekiq.redis { |con| con.smembers('priority-queues') }
44
+ .map{ |key| key.gsub('priority-queue:', '') }
45
+ .sort
46
+ .map { |q| Queue.new(q) }
47
+ end
48
+
49
+ end
50
+
51
+ SubqueueCount = Struct.new(:name, :size)
52
+
53
+ class Job < Sidekiq::Job
54
+
55
+ attr_reader :priority
56
+ attr_reader :subqueue
57
+
58
+ def initialize(item, queue_name = nil, priority = nil)
59
+ @args = nil
60
+ @value = item
61
+ @item = item.is_a?(Hash) ? item : parse(item)
62
+ @queue = queue_name || @item['queue']
63
+ @subqueue = @item['subqueue']
64
+ @priority = priority
65
+ end
66
+
67
+ def delete
68
+ count = Sidekiq.redis do |conn|
69
+ conn.zrem("priority-queue:#{@queue}", @value)
70
+ end
71
+ count != 0
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,68 @@
1
+ module Sidekiq
2
+ module PriorityQueue
3
+ class Client
4
+
5
+ # inserted into Sidekiq's Client as middleware
6
+ def call(worker_class, item, queue, redis_pool)
7
+ if item['priority']
8
+ sadd('priority-queues', queue)
9
+ zadd(queue, item['priority'], item)
10
+ return item['jid']
11
+ elsif item['subqueue']
12
+ # replace the proc with what it returns
13
+ sadd('priority-queues', queue)
14
+ item['subqueue'] = resolve_subqueue(item['subqueue'], item['args'])
15
+ priority = fetch_and_add(queue, item['subqueue'], item)
16
+ zadd(queue, priority, item)
17
+ return item['jid']
18
+ else
19
+ # continue pushing the normal Sidekiq way
20
+ yield
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def resolve_subqueue(subqueue, job_args)
27
+ return subqueue unless subqueue.respond_to?(:call)
28
+
29
+ subqueue.call(job_args)
30
+ end
31
+
32
+ def zadd(queue, score, item)
33
+ Sidekiq.redis do |conn|
34
+ queue = "priority-queue:#{queue}"
35
+ conn.zadd(queue, score, item.to_json)
36
+ return item
37
+ end
38
+ end
39
+
40
+ def sadd(set, member)
41
+ Sidekiq.redis do |conn|
42
+ conn.sadd(set,member)
43
+ end
44
+ end
45
+
46
+ def fetch_and_add(queue, subqueue, item)
47
+ Sidekiq.redis do |conn|
48
+ priority = conn.zincrby("priority-queue-counts:#{queue}", 1, subqueue)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ Sidekiq::Client.class_eval do
56
+ def push(item)
57
+ normed = normalize_item(item)
58
+ payload = process_single(item['class'], normed)
59
+
60
+ # if payload is a JID because the middleware already pushed then just return the JID
61
+ return payload if payload.is_a?(String)
62
+
63
+ if payload
64
+ raw_push([payload])
65
+ payload['jid']
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module PriorityQueue
5
+ class CombinedFetch
6
+ attr_reader :fetches
7
+
8
+ def initialize(fetches = [])
9
+ @fetches = fetches
10
+ end
11
+
12
+ def retrieve_work
13
+ fetches.each do |fetch|
14
+ work = fetch.retrieve_work
15
+ return work if work
16
+ end
17
+ end
18
+
19
+ def self.configure(&block)
20
+ combined_fetch = self.new
21
+ yield combined_fetch
22
+
23
+ combined_fetch
24
+ end
25
+
26
+ def add(fetch)
27
+ fetches << fetch
28
+ end
29
+
30
+ def bulk_requeue(inprogress, options)
31
+ # ReliableFetch#bulk_equeue ignores inprogress, so it's safe to call both
32
+ fetches.each do |f|
33
+ if [Fetch, ReliableFetch].any? { |klass| f.is_a?(klass) }
34
+ jobs_to_requeue = inprogress.select{|uow| uow.queue.start_with?('priority-queue:') }
35
+ f.bulk_requeue(jobs_to_requeue, options)
36
+ else
37
+ jobs_to_requeue = inprogress.reject{|uow| uow.queue.start_with?('priority-queue:') }
38
+ f.bulk_requeue(jobs_to_requeue, options)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+ require 'sidekiq'
3
+
4
+ module Sidekiq
5
+ module PriorityQueue
6
+ class Fetch
7
+
8
+ UnitOfWork = Struct.new(:queue, :job) do
9
+ def acknowledge
10
+ Sidekiq.redis do |conn|
11
+ unless subqueue.nil?
12
+ count = conn.zincrby(subqueue_counts, -1, subqueue)
13
+ conn.zrem(subqueue_counts, subqueue) if count < 1
14
+ end
15
+ end
16
+ end
17
+
18
+ def queue_name
19
+ queue.sub(/.*queue:/, '')
20
+ end
21
+
22
+ def subqueue
23
+ @parsed_job ||= JSON.parse(job)
24
+ @parsed_job['subqueue']
25
+ end
26
+
27
+ def subqueue_counts
28
+ "priority-queue-counts:#{queue_name}"
29
+ end
30
+
31
+ def requeue
32
+ Sidekiq.redis do |conn|
33
+ conn.zadd(queue, 0, job)
34
+ end
35
+ end
36
+ end
37
+
38
+ def initialize(options)
39
+ @strictly_ordered_queues = !!options[:strict]
40
+ @queues = options[:queues].map { |q| "priority-queue:#{q}" }
41
+ @queues = @queues.uniq if @strictly_ordered_queues
42
+ end
43
+
44
+ def retrieve_work
45
+ work = @queues.detect{ |q| job = zpopmin(q); break [q,job] if job }
46
+ UnitOfWork.new(*work) if work
47
+ end
48
+
49
+ def zpopmin(queue)
50
+ Sidekiq.redis do |con|
51
+ @script_sha ||= con.script(:load, Sidekiq::PriorityQueue::Scripts::ZPOPMIN)
52
+ con.evalsha(@script_sha, [queue])
53
+ end
54
+ end
55
+
56
+ def queues_cmd
57
+ if @strictly_ordered_queues
58
+ @queues
59
+ else
60
+ @queues.shuffle.uniq
61
+ end
62
+ end
63
+
64
+ def bulk_requeue(inprogress, options)
65
+ return if inprogress.empty?
66
+
67
+ Sidekiq.logger.debug { "Re-queueing terminated jobs" }
68
+ jobs_to_requeue = {}
69
+ inprogress.each do |unit_of_work|
70
+ jobs_to_requeue[unit_of_work.queue] ||= []
71
+ jobs_to_requeue[unit_of_work.queue] << unit_of_work.job
72
+ end
73
+
74
+ Sidekiq.redis do |conn|
75
+ conn.pipelined do
76
+ jobs_to_requeue.each do |queue, jobs|
77
+ conn.zadd(queue, jobs.map{|j| [0,j] })
78
+ end
79
+ end
80
+ end
81
+ Sidekiq.logger.info("Pushed #{inprogress.size} jobs back to Redis")
82
+ rescue => ex
83
+ Sidekiq.logger.warn("Failed to requeue #{inprogress.size} jobs: #{ex.message}")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+ require 'sidekiq'
3
+
4
+ module Sidekiq
5
+ module PriorityQueue
6
+ class ReliableFetch
7
+
8
+ UnitOfWork = Struct.new(:queue, :job, :wip_queue) do
9
+ def acknowledge
10
+ Sidekiq.redis do |conn|
11
+ conn.srem(wip_queue, job)
12
+ unless subqueue.nil?
13
+ count = conn.zincrby(subqueue_counts, -1, subqueue)
14
+ conn.zrem(subqueue_counts, subqueue) if count < 1
15
+ end
16
+ end
17
+ end
18
+
19
+ def queue_name
20
+ queue.sub(/.*queue:/, '')
21
+ end
22
+
23
+ def subqueue
24
+ @parsed_job ||= JSON.parse(job)
25
+ @parsed_job['subqueue']
26
+ end
27
+
28
+ def subqueue_counts
29
+ "priority-queue-counts:#{queue_name}"
30
+ end
31
+
32
+ def requeue
33
+ # Nothing needed. Jobs are in WIP queue.
34
+ end
35
+ end
36
+
37
+ def initialize(options)
38
+ @strictly_ordered_queues = !!options[:strict]
39
+ @queues = options[:queues].map { |q| "priority-queue:#{q}" }
40
+ @queues = @queues.uniq if @strictly_ordered_queues
41
+ @process_index = options[:index] || ENV['PROCESS_INDEX']
42
+ end
43
+
44
+ def retrieve_work
45
+ work = @queues.detect do |q|
46
+ job = zpopmin_sadd(q, wip_queue(q));
47
+ break [q,job] if job
48
+ end
49
+ UnitOfWork.new(*work, wip_queue(work.first)) if work
50
+ end
51
+
52
+ def wip_queue(q)
53
+ "#{q}_#{Socket.gethostname}_#{@process_index}"
54
+ end
55
+
56
+ def zpopmin_sadd(queue, wip_queue)
57
+ Sidekiq.redis do |con|
58
+ @script_sha ||= con.script(:load, Sidekiq::PriorityQueue::Scripts::ZPOPMIN_SADD)
59
+ con.evalsha(@script_sha, [queue, wip_queue])
60
+ end
61
+ end
62
+
63
+ def spop(wip_queue)
64
+ Sidekiq.redis{ |con| con.spop(wip_queue) }
65
+ end
66
+
67
+ def queues_cmd
68
+ if @strictly_ordered_queues
69
+ @queues
70
+ else
71
+ @queues.shuffle.uniq
72
+ end
73
+ end
74
+
75
+ def bulk_requeue(_inprogress, options)
76
+ Sidekiq.logger.debug { "Re-queueing terminated jobs" }
77
+ process_index = options[:index] || ENV['PROCESS_INDEX']
78
+ self.class.requeue_wip_jobs(options[:queues], process_index)
79
+ end
80
+
81
+ def self.resume_wip_jobs(queues, process_index)
82
+ Sidekiq.logger.debug { "Re-queueing WIP jobs" }
83
+ process_index ||= ENV['PROCESS_INDEX']
84
+ requeue_wip_jobs(queues, process_index)
85
+ end
86
+
87
+ Sidekiq.configure_server do |config|
88
+ config.on(:startup) do
89
+ if reliable_fetch_active?(config)
90
+ Sidekiq::PriorityQueue::ReliableFetch.resume_wip_jobs(config.options[:queues], config.options[:index])
91
+ end
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def self.reliable_fetch_active?(config)
98
+ return true if config.options[:fetch].is_a?(Sidekiq::PriorityQueue::ReliableFetch)
99
+ return config.options[:fetch].is_a?(Sidekiq::PriorityQueue::CombinedFetch) &&
100
+ config.options[:fetch].fetches.any? { |f| f.is_a?(Sidekiq::PriorityQueue::ReliableFetch) }
101
+ end
102
+
103
+ def self.requeue_wip_jobs(queues, index)
104
+ jobs_to_requeue = {}
105
+ Sidekiq.redis do |conn|
106
+ queues.map { |q| "priority-queue:#{q}" }.each do |q|
107
+ wip_queue = "#{q}_#{Socket.gethostname}_#{index}"
108
+ jobs_to_requeue[q] = []
109
+ while job = conn.spop(wip_queue) do
110
+ jobs_to_requeue[q] << job
111
+ end
112
+ end
113
+
114
+ conn.pipelined do
115
+ jobs_to_requeue.each do |queue, jobs|
116
+ return unless jobs.size > 0
117
+ conn.zadd(queue, jobs.map{|j| [0,j] })
118
+ end
119
+ end
120
+ end
121
+ Sidekiq.logger.info("Pushed #{ jobs_to_requeue.map{|q| q.size }.reduce(:+) } jobs back to Redis")
122
+ rescue => ex
123
+ Sidekiq.logger.warn("Failed to requeue #{ jobs_to_requeue.map{|q| q.size }.reduce(:+) } jobs: #{ex.message}")
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Sidekiq
5
+ module PriorityQueue
6
+ module Scripts
7
+
8
+ ZPOPMIN = %q(
9
+ local resp = redis.call('zrange', KEYS[1], '0', '0')
10
+ if (resp[1] ~= nil) then
11
+ local val = resp[# resp]
12
+ redis.call('zrem', KEYS[1], val)
13
+ return val
14
+ else
15
+ return false
16
+ end
17
+ )
18
+
19
+ ZPOPMIN_SADD = %q(
20
+ local resp = redis.call('zrange', KEYS[1], '0', '0')
21
+ if (resp[1] ~= nil) then
22
+ local val = resp[# resp]
23
+ redis.call('zrem', KEYS[1], val)
24
+ redis.call('sadd', KEYS[2], val)
25
+ return val
26
+ else
27
+ return false
28
+ end
29
+ )
30
+
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Don't require in production code.
4
+ # This disables the middleware and falls back to normal push, meaning in tests it will use inline/fake mode.
5
+ # Prioritization doesn't make any sense in inline or fake tests anyway.
6
+ module Sidekiq
7
+ module PriorityQueue
8
+ module TestingClient
9
+ def call(worker_class, item, queue, redis_pool)
10
+ testing_verify_subqueue(item) if item['subqueue'] && !item['priority']
11
+ yield # continue pushing the normal Sidekiq way
12
+ end
13
+
14
+ # Help testing the lambda; raise in case it's invalid.
15
+ def testing_verify_subqueue(item)
16
+ subqueue = resolve_subqueue(item['subqueue'], item['args'])
17
+ serialized = "#{subqueue}"
18
+
19
+ raise "subqueue shouldn't be nil" if subqueue.nil?
20
+ raise "subqueue shouldn't be empty" if serialized == ""
21
+ end
22
+ end
23
+
24
+ Sidekiq::PriorityQueue::Client.prepend TestingClient
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ require 'sidekiq/web'
2
+
3
+
4
+ module Sidekiq::PriorityQueue
5
+ module Web
6
+
7
+ ROOT = File.expand_path('../web', __FILE__)
8
+
9
+ def self.registered(app)
10
+ app.tabs['Priority Queues'] = 'priority_queues'
11
+
12
+ app.get '/priority_queues' do
13
+ @queues = Queue.all
14
+ render(:erb, File.read("#{ROOT}/views/priority_queues.erb"))
15
+ end
16
+
17
+ app.get '/priority_queues/:name' do
18
+ @name = route_params[:name]
19
+ halt(404) unless @name
20
+
21
+ @count = (params['count'] || 25).to_i
22
+ @queue = Sidekiq::Queue.new(@name)
23
+ (@current_page, @total_size, @messages) = page("priority-queue:#{@name}", params['page'], @count)
24
+ @subqueue_counts = Sidekiq.redis do |con|
25
+ con.zrevrange("priority-queue-counts:#{@name}", 0, params['subqueue_count'] || 10, withscores: true)
26
+ end.map { |name, count| SubqueueCount.new(name, count) }
27
+
28
+ @messages = @messages.map{ |msg| Job.new(msg.first, @name, msg.last) }
29
+ render(:erb, File.read("#{ROOT}/views/priority_queue.erb"))
30
+ end
31
+
32
+ app.post "/priority_queues/:name/delete" do
33
+ name = route_params[:name]
34
+ Job.new(params['key_val'], name).delete
35
+ redirect_with_query("#{root_path}priority_queues/#{CGI.escape(name)}")
36
+ end
37
+
38
+ end
39
+ end
40
+ end
41
+
42
+ ::Sidekiq::Web.register Sidekiq::PriorityQueue::Web
@@ -0,0 +1,24 @@
1
+
2
+ <% if @total_size > @count %>
3
+ <ul class="pagination pull-right flip">
4
+ <li class="<%= 'disabled' if @current_page == 1 %>">
5
+ <a href="<%= url %>?page=1">&laquo;</a>
6
+ </li>
7
+ <% if @current_page > 1 %>
8
+ <li>
9
+ <a href="<%= url %>?<%= qparams(page: @current_page - 1) %>"><%= @current_page - 1 %></a>
10
+ </li>
11
+ <% end %>
12
+ <li class="disabled">
13
+ <a href="<%= url %>?<%= qparams(page: @current_page) %>"><%= @current_page %></a>
14
+ </li>
15
+ <% if @total_size > @current_page * @count %>
16
+ <li>
17
+ <a href="<%= url %>?<%= qparams(page: @current_page + 1) %>"><%= @current_page + 1 %></a>
18
+ </li>
19
+ <% end %>
20
+ <li class="<%= 'disabled' if @total_size <= @current_page * @count %>">
21
+ <a href="<%= url %>?<%= qparams(page: (@total_size.to_f / @count).ceil) %>">&raquo;</a>
22
+ </li>
23
+ </ul>
24
+ <% end %>
@@ -0,0 +1,74 @@
1
+ <header class="row">
2
+ <div class="col-sm-5">
3
+ <h3>
4
+ <%= t('CurrentMessagesInQueue', :queue => h(@name)) %>
5
+
6
+ <span class="badge badge-secondary"><%= number_with_delimiter(@total_size) %></span>
7
+ </h3>
8
+ </div>
9
+ <div class="col-sm-4 pull-right flip">
10
+ <%= erb :_paging, locals: { url: "#{root_path}priority_queues/#{CGI.escape(@name)}" } %>
11
+ </div>
12
+ </header>
13
+ <div class="row">
14
+ <div class="col-sm-12">
15
+ <h3>Biggest subqueues</h3>
16
+
17
+ <summary>
18
+ <details>
19
+ <div class="table_container">
20
+ <table class="table table-hover table-bordered table-striped">
21
+ <thead>
22
+ <th>Subqueue name</th>
23
+ <th>Subqueue size</th>
24
+ </thead>
25
+ <% @subqueue_counts.each do |subqueue_count| %>
26
+ <tr>
27
+ <td><%= subqueue_count.name %></td>
28
+ <td><%= subqueue_count.size %></td>
29
+ </tr>
30
+ <% end %>
31
+ </table>
32
+ </div>
33
+ </details>
34
+ </summary>
35
+
36
+ </div>
37
+ </div>
38
+ <hr />
39
+ <div class="table_container">
40
+ <table class="queue table table-hover table-bordered table-striped">
41
+ <thead>
42
+ <th><%= t('Job') %></th>
43
+ <th><%= t('Arguments') %></th>
44
+ <th><%= t('Priority') %></th>
45
+ <th><%= t('Subqueue') %></th>
46
+ <th></th>
47
+ </thead>
48
+ <% @messages.each_with_index do |msg, index| %>
49
+ <tr>
50
+ <td><%= h(msg.display_class) %></td>
51
+ <td>
52
+ <% a = msg.display_args %>
53
+ <% if a.inspect.size > 100 %>
54
+ <span class="worker_<%= index %>"><%= h(a.inspect[0..100]) + "... " %></span>
55
+ <button data-toggle="collapse" data-target=".worker_<%= index %>" class="btn btn-default btn-xs"><%= t('ShowAll') %></button>
56
+ <div class="toggle worker_<%= index %>"><%= display_args(a) %></div>
57
+ <% else %>
58
+ <%= display_args(msg.display_args) %>
59
+ <% end %>
60
+ </td>
61
+ <td><%= msg.priority %></td>
62
+ <td><%= msg.subqueue %></td>
63
+ <td>
64
+ <form action="<%= root_path %>priority_queues/<%= CGI.escape(@name) %>/delete" method="post">
65
+ <%= csrf_tag %>
66
+ <input name="key_val" value="<%= h msg.value %>" type="hidden" />
67
+ <input class="btn btn-danger btn-xs" type="submit" name="delete" value="<%= t('Delete') %>" data-confirm="<%= t('AreYouSure') %>" />
68
+ </form>
69
+ </td>
70
+ </tr>
71
+ <% end %>
72
+ </table>
73
+ </div>
74
+ <%= erb :_paging, locals: { url: "#{root_path}priority_queues/#{@name}" } %>
@@ -0,0 +1,27 @@
1
+ <h3><%= t('Queues') %></h3>
2
+
3
+ <div class="table_container">
4
+ <table class="queues table table-hover table-bordered table-striped table-white">
5
+ <thead>
6
+ <th><%= t('Queue') %></th>
7
+ <th><%= t('Size') %></th>
8
+ <th><%= t('Latency') %></th>
9
+ <th><%= t('Actions') %></th>
10
+ </thead>
11
+ <% @queues.each do |queue| %>
12
+ <tr>
13
+ <td>
14
+ <a href="<%= root_path %>priority_queues/<%= CGI.escape(queue.name) %>"><%= h queue.name %></a>
15
+ </td>
16
+ <td><%= queue.size %> </td>
17
+ <td><%# number_with_delimiter(queue.latency.round(2)) %> </td>
18
+ <td class="delete-confirm">
19
+ <form action="<%=root_path %>priority_queues/<%= CGI.escape(queue.name) %>" method="post">
20
+ <%= csrf_tag %>
21
+ <input class="btn btn-danger btn-xs" type="submit" name="delete" value="<%= t('Delete') %>" data-confirm="<%= t('AreYouSureDeleteQueue', :queue => h(queue.name)) %>" />
22
+ </form>
23
+ </td>
24
+ </tr>
25
+ <% end %>
26
+ </table>
27
+ </div>
@@ -0,0 +1,16 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'sidekiq-priority_queue'
3
+ s.version = '1.0.2'
4
+ s.date = '2018-07-31'
5
+ s.summary = "Priority Queuing for Sidekiq"
6
+ s.description = "An extension for Sidekiq allowing jobs in a single queue to be executed by a priority score rather than FIFO"
7
+ s.authors = ["Jacob Matthews", "Petr Kopac"]
8
+ s.email = 'petr@chartmogul.com'
9
+ s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|pkg)/}) }
10
+ s.homepage = 'https://github.com/chartmogul/sidekiq-priority_queue'
11
+ s.license = 'MIT'
12
+ s.required_ruby_version = '>= 2.5.0'
13
+
14
+ s.add_dependency 'sidekiq', '>= 6'
15
+ s.add_development_dependency 'minitest', '~> 5.10', '>= 5.10.1'
16
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sidekiq-priority_queue
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Jacob Matthews
8
+ - Petr Kopac
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2018-07-31 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: sidekiq
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '6'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '6'
28
+ - !ruby/object:Gem::Dependency
29
+ name: minitest
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '5.10'
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: 5.10.1
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - "~>"
43
+ - !ruby/object:Gem::Version
44
+ version: '5.10'
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 5.10.1
48
+ description: An extension for Sidekiq allowing jobs in a single queue to be executed
49
+ by a priority score rather than FIFO
50
+ email: petr@chartmogul.com
51
+ executables: []
52
+ extensions: []
53
+ extra_rdoc_files: []
54
+ files:
55
+ - ".github/workflows/tests.yml"
56
+ - ".gitignore"
57
+ - Gemfile
58
+ - Gemfile.lock
59
+ - LICENSE
60
+ - README.md
61
+ - Rakefile
62
+ - bin/sidekiqload
63
+ - docker-compose.yml
64
+ - lib/sidekiq/priority_queue.rb
65
+ - lib/sidekiq/priority_queue/api.rb
66
+ - lib/sidekiq/priority_queue/client.rb
67
+ - lib/sidekiq/priority_queue/combined_fetch.rb
68
+ - lib/sidekiq/priority_queue/fetch.rb
69
+ - lib/sidekiq/priority_queue/reliable_fetch.rb
70
+ - lib/sidekiq/priority_queue/scripts.rb
71
+ - lib/sidekiq/priority_queue/testing.rb
72
+ - lib/sidekiq/priority_queue/web.rb
73
+ - lib/sidekiq/priority_queue/web/views/_paging.erb
74
+ - lib/sidekiq/priority_queue/web/views/priority_queue.erb
75
+ - lib/sidekiq/priority_queue/web/views/priority_queues.erb
76
+ - sidekiq-priority_queue.gemspec
77
+ homepage: https://github.com/chartmogul/sidekiq-priority_queue
78
+ licenses:
79
+ - MIT
80
+ metadata: {}
81
+ post_install_message:
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 2.5.0
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ requirements: []
96
+ rubygems_version: 3.1.4
97
+ signing_key:
98
+ specification_version: 4
99
+ summary: Priority Queuing for Sidekiq
100
+ test_files: []