qmore 0.5.0
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.
- data/.gitignore +9 -0
- data/.travis.yml +5 -0
- data/CHANGELOG +4 -0
- data/Gemfile +4 -0
- data/LICENSE +20 -0
- data/README.md +118 -0
- data/Rakefile +2 -0
- data/config.ru +8 -0
- data/lib/qmore-server.rb +5 -0
- data/lib/qmore.rb +22 -0
- data/lib/qmore/attributes.rb +191 -0
- data/lib/qmore/job_reserver.rb +37 -0
- data/lib/qmore/server.rb +93 -0
- data/lib/qmore/server/views/dynamicqueues.erb +64 -0
- data/lib/qmore/server/views/priorities.erb +78 -0
- data/lib/qmore/version.rb +3 -0
- data/qmore.gemspec +33 -0
- data/spec/attributes_spec.rb +235 -0
- data/spec/job_reserver_spec.rb +87 -0
- data/spec/redis-test.conf +312 -0
- data/spec/server_spec.rb +237 -0
- data/spec/spec_helper.rb +49 -0
- metadata +172 -0
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/CHANGELOG
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2013 Matt Conway (matt@conwaysplace.com)
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
Qmore is a qless plugin that gives one more control over how queues are processed. Qmore allows one to specify the queues a worker processes by the use of wildcards, negations, or dynamic look up from redis. It also allows one to specify the relative priority between queues (rather than within a single queue). It plugs into the Qless webapp to make it easy to manage the queues.
|
2
|
+
|
3
|
+
Authored against Qless 0.9.3, so it at least works with that - try running the tests if you use a different version of qless
|
4
|
+
|
5
|
+
[](http://travis-ci.org/wr0ngway/qmore)
|
6
|
+
|
7
|
+
Usage
|
8
|
+
-----
|
9
|
+
|
10
|
+
To use the rake tasks built into qless, just adding qless to your Gemfile should cause it to get required before the task executes. If you aren't using a gemfile, then you'll need to require qmore directly so that it sets up ENV['JOB_RESERVER'] to use Qmore::JobReserver.
|
11
|
+
|
12
|
+
Alternatively, if you have some other way of launching workers (e.g. qless-pool), you can assign the reserver explicitly in the setup rake task or some other initializer:
|
13
|
+
|
14
|
+
Qless::Pool.pool_factory.reserver_class = Qmore::JobReserver
|
15
|
+
Qmore.client == Qless::Pool.pool_factory.client
|
16
|
+
|
17
|
+
To enable the web UI, use a config.ru similar to the following depending on your environment:
|
18
|
+
|
19
|
+
require 'qless/server'
|
20
|
+
require 'qmore-server'
|
21
|
+
|
22
|
+
Qless::Server.client = Qless::Client.new(:host => "some-host", :port => 7000)
|
23
|
+
Qmore.client = Qless::Server.client
|
24
|
+
run Qless::Server.new(Qmore.client)
|
25
|
+
|
26
|
+
Dynamic Queues
|
27
|
+
--------------
|
28
|
+
|
29
|
+
Start your workers with a QUEUES that can contain '\*' for zero-or more of any character, '!' to exclude the following pattern, or @key to look up the patterns from redis. Some examples help:
|
30
|
+
|
31
|
+
QUEUES='foo' rake qless:work
|
32
|
+
|
33
|
+
Pulls jobs from the queue 'foo'
|
34
|
+
|
35
|
+
QUEUES='*' rake qless:work
|
36
|
+
|
37
|
+
Pulls jobs from any queue
|
38
|
+
|
39
|
+
QUEUES='*foo' rake qless:work
|
40
|
+
|
41
|
+
Pulls jobs from queues that end in foo
|
42
|
+
|
43
|
+
QUEUES='*foo*' rake qless:work
|
44
|
+
|
45
|
+
Pulls jobs from queues whose names contain foo
|
46
|
+
|
47
|
+
QUEUES='*foo*,!foobar' rake qless:work
|
48
|
+
|
49
|
+
Pulls jobs from queues whose names contain foo except the foobar queue
|
50
|
+
|
51
|
+
QUEUES='*foo*,!*bar' rake qless:work
|
52
|
+
|
53
|
+
Pulls jobs from queues whose names contain foo except queues whose names end in bar
|
54
|
+
|
55
|
+
QUEUES='@key' rake qless:work
|
56
|
+
|
57
|
+
Pulls jobs from queue names stored in redis (use Qless.set\_dynamic\_queue("key", ["queuename1", "queuename2"]) to set them)
|
58
|
+
|
59
|
+
QUEUES='*,!@key' rake qless:work
|
60
|
+
|
61
|
+
Pulls jobs from any queue execept ones stored in redis
|
62
|
+
|
63
|
+
QUEUES='@' rake qless:work
|
64
|
+
|
65
|
+
Pulls jobs from queue names stored in redis using the hostname of the worker
|
66
|
+
|
67
|
+
Qless.set_dynamic_queue("key", ["*foo*", "!*bar"])
|
68
|
+
QUEUES='@key' rake qless:work
|
69
|
+
|
70
|
+
Pulls jobs from queue names stored in redis, with wildcards/negations
|
71
|
+
|
72
|
+
task :custom_worker do
|
73
|
+
ENV['QUEUES'] = "*foo*,!*bar"
|
74
|
+
Rake::Task['qless:work'].invoke
|
75
|
+
end
|
76
|
+
|
77
|
+
From a custom rake script
|
78
|
+
|
79
|
+
Queue Priority
|
80
|
+
--------------
|
81
|
+
|
82
|
+
Start your workers with a QUEUES that contains many queue names - the priority is most useful when using wildcards.
|
83
|
+
|
84
|
+
The qmore priority web ui is shown as a tab in the qless web UI, and allows you to define the queue priorities. To activate it, you need to require 'qmore-server' in whatever initializer you use to bring up qless-web.
|
85
|
+
|
86
|
+
Then you should set use the web ui to determine the order a worker will pick a queue for processing. The "Fairly" checkbox makes all queues that match that pattern get ordered in a random fashion.
|
87
|
+
|
88
|
+
For example, say my qless system has the queues:
|
89
|
+
|
90
|
+
low_foo, low_bar, low_baz, high_foo, high_bar, high_baz, otherqueue, somequeue, myqueue
|
91
|
+
|
92
|
+
And I run my worker with QUEUES=\*
|
93
|
+
|
94
|
+
If I set my patterns like:
|
95
|
+
|
96
|
+
high\_\* (fairly unchecked)
|
97
|
+
default (fairly unchecked)
|
98
|
+
low\_\* (fairly unchecked)
|
99
|
+
|
100
|
+
Then, the worker will scan the queues for work in this order:
|
101
|
+
high_bar, high_baz, high_foo, myqueue, otherqueue, somequeue, low_bar, low_baz, low_foo
|
102
|
+
|
103
|
+
If I set my patterns like:
|
104
|
+
|
105
|
+
high\_\* (fairly checked)
|
106
|
+
default (fairly checked)
|
107
|
+
low\_\* (fairly checked)
|
108
|
+
|
109
|
+
Then, the worker will scan the queues for work in this order:
|
110
|
+
|
111
|
+
\*[high_bar, high_baz, high_foo].shuffle, \*[myqueue, otherqueue, somequeue].shuffle, \*[low_bar, low_baz, low_foo].shuffle
|
112
|
+
|
113
|
+
|
114
|
+
Contributors
|
115
|
+
------------
|
116
|
+
|
117
|
+
Matt Conway ( https://github.com/wr0ngway )
|
118
|
+
Bert Goethals ( https://github.com/Bertg )
|
data/Rakefile
ADDED
data/config.ru
ADDED
data/lib/qmore-server.rb
ADDED
data/lib/qmore.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'qless'
|
2
|
+
require 'qless/worker'
|
3
|
+
require 'qmore/attributes'
|
4
|
+
require 'qmore/job_reserver'
|
5
|
+
|
6
|
+
module Qmore
|
7
|
+
|
8
|
+
def self.client=(client)
|
9
|
+
@client = client
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.client
|
13
|
+
@client ||= Qless::Client.new
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
module Qless
|
18
|
+
module JobReservers
|
19
|
+
QmoreReserver = Qmore::JobReserver
|
20
|
+
end
|
21
|
+
end
|
22
|
+
ENV['JOB_RESERVER'] ||= 'QmoreReserver'
|
@@ -0,0 +1,191 @@
|
|
1
|
+
require 'multi_json'
|
2
|
+
|
3
|
+
module Qmore
|
4
|
+
DYNAMIC_QUEUE_KEY = "qmore:dynamic"
|
5
|
+
PRIORITY_KEY = "qmore:priority"
|
6
|
+
DYNAMIC_FALLBACK_KEY = "default"
|
7
|
+
|
8
|
+
module Attributes
|
9
|
+
extend self
|
10
|
+
|
11
|
+
def redis
|
12
|
+
Qmore.client.redis
|
13
|
+
end
|
14
|
+
|
15
|
+
def decode(data)
|
16
|
+
MultiJson.load(data) if data
|
17
|
+
end
|
18
|
+
|
19
|
+
def encode(data)
|
20
|
+
MultiJson.dump(data)
|
21
|
+
end
|
22
|
+
|
23
|
+
def get_dynamic_queue(key, fallback=['*'])
|
24
|
+
data = redis.hget(DYNAMIC_QUEUE_KEY, key)
|
25
|
+
queue_names = decode(data)
|
26
|
+
|
27
|
+
if queue_names.nil? || queue_names.size == 0
|
28
|
+
data = redis.hget(DYNAMIC_QUEUE_KEY, DYNAMIC_FALLBACK_KEY)
|
29
|
+
queue_names = decode(data)
|
30
|
+
end
|
31
|
+
|
32
|
+
if queue_names.nil? || queue_names.size == 0
|
33
|
+
queue_names = fallback
|
34
|
+
end
|
35
|
+
|
36
|
+
return queue_names
|
37
|
+
end
|
38
|
+
|
39
|
+
def set_dynamic_queue(key, values)
|
40
|
+
if values.nil? or values.size == 0
|
41
|
+
redis.hdel(DYNAMIC_QUEUE_KEY, key)
|
42
|
+
else
|
43
|
+
redis.hset(DYNAMIC_QUEUE_KEY, key, encode(values))
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def set_dynamic_queues(dynamic_queues)
|
48
|
+
redis.multi do
|
49
|
+
redis.del(DYNAMIC_QUEUE_KEY)
|
50
|
+
dynamic_queues.each do |k, v|
|
51
|
+
set_dynamic_queue(k, v)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def get_dynamic_queues
|
57
|
+
result = {}
|
58
|
+
queues = redis.hgetall(DYNAMIC_QUEUE_KEY)
|
59
|
+
queues.each {|k, v| result[k] = decode(v) }
|
60
|
+
result[DYNAMIC_FALLBACK_KEY] ||= ['*']
|
61
|
+
return result
|
62
|
+
end
|
63
|
+
|
64
|
+
def get_priority_buckets
|
65
|
+
priorities = Array(redis.lrange(PRIORITY_KEY, 0, -1))
|
66
|
+
priorities = priorities.collect {|p| decode(p) }
|
67
|
+
priorities << {'pattern' => 'default'} unless priorities.find {|b| b['pattern'] == 'default' }
|
68
|
+
return priorities
|
69
|
+
end
|
70
|
+
|
71
|
+
def set_priority_buckets(data)
|
72
|
+
redis.multi do
|
73
|
+
redis.del(PRIORITY_KEY)
|
74
|
+
Array(data).each do |v|
|
75
|
+
redis.rpush(PRIORITY_KEY, encode(v))
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Returns a list of queues to use when searching for a job.
|
81
|
+
#
|
82
|
+
# A splat ("*") means you want every queue (in alpha order) - this
|
83
|
+
# can be useful for dynamically adding new queues.
|
84
|
+
#
|
85
|
+
# The splat can also be used as a wildcard within a queue name,
|
86
|
+
# e.g. "*high*", and negation can be indicated with a prefix of "!"
|
87
|
+
#
|
88
|
+
# An @key can be used to dynamically look up the queue list for key from redis.
|
89
|
+
# If no key is supplied, it defaults to the worker's hostname, and wildcards
|
90
|
+
# and negations can be used inside this dynamic queue list. Set the queue
|
91
|
+
# list for a key with set_dynamic_queue(key, ["q1", "q2"]
|
92
|
+
#
|
93
|
+
def expand_queues(queue_patterns, real_queues)
|
94
|
+
queue_patterns = queue_patterns.dup
|
95
|
+
real_queues = real_queues.dup
|
96
|
+
|
97
|
+
matched_queues = []
|
98
|
+
|
99
|
+
while q = queue_patterns.shift
|
100
|
+
q = q.to_s
|
101
|
+
|
102
|
+
if q =~ /^(!)?@(.*)/
|
103
|
+
key = $2.strip
|
104
|
+
key = Socket.gethostname if key.size == 0
|
105
|
+
|
106
|
+
add_queues = get_dynamic_queue(key)
|
107
|
+
add_queues.map! { |q| q.gsub!(/^!/, '') || q.gsub!(/^/, '!') } if $1
|
108
|
+
|
109
|
+
queue_patterns.concat(add_queues)
|
110
|
+
next
|
111
|
+
end
|
112
|
+
|
113
|
+
if q =~ /^!/
|
114
|
+
negated = true
|
115
|
+
q = q[1..-1]
|
116
|
+
end
|
117
|
+
|
118
|
+
patstr = q.gsub(/\*/, ".*")
|
119
|
+
pattern = /^#{patstr}$/
|
120
|
+
if negated
|
121
|
+
matched_queues -= matched_queues.grep(pattern)
|
122
|
+
else
|
123
|
+
matches = real_queues.grep(/^#{pattern}$/)
|
124
|
+
matches = [q] if matches.size == 0 && q == patstr
|
125
|
+
matched_queues.concat(matches)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
return matched_queues.uniq.sort
|
130
|
+
end
|
131
|
+
|
132
|
+
def prioritize_queues(priority_buckets, real_queues)
|
133
|
+
real_queues = real_queues.dup
|
134
|
+
priority_buckets = priority_buckets.dup
|
135
|
+
|
136
|
+
result = []
|
137
|
+
default_idx = -1, default_fairly = false;
|
138
|
+
|
139
|
+
# Walk the priority patterns, extract each into its own bucket
|
140
|
+
priority_buckets.each do |bucket|
|
141
|
+
bucket_pattern = bucket['pattern']
|
142
|
+
fairly = bucket['fairly']
|
143
|
+
|
144
|
+
# note the position of the default bucket for inserting the remaining queues at that location
|
145
|
+
if bucket_pattern == 'default'
|
146
|
+
default_idx = result.size
|
147
|
+
default_fairly = fairly
|
148
|
+
next
|
149
|
+
end
|
150
|
+
|
151
|
+
bucket_queues, remaining = [], []
|
152
|
+
|
153
|
+
patterns = bucket_pattern.split(',')
|
154
|
+
patterns.each do |pattern|
|
155
|
+
pattern = pattern.strip
|
156
|
+
|
157
|
+
if pattern =~ /^!/
|
158
|
+
negated = true
|
159
|
+
pattern = pattern[1..-1]
|
160
|
+
end
|
161
|
+
|
162
|
+
patstr = pattern.gsub(/\*/, ".*")
|
163
|
+
pattern = /^#{patstr}$/
|
164
|
+
|
165
|
+
|
166
|
+
if negated
|
167
|
+
bucket_queues -= bucket_queues.grep(pattern)
|
168
|
+
else
|
169
|
+
bucket_queues.concat(real_queues.grep(pattern))
|
170
|
+
end
|
171
|
+
|
172
|
+
end
|
173
|
+
|
174
|
+
bucket_queues.uniq!
|
175
|
+
bucket_queues.shuffle! if fairly
|
176
|
+
real_queues = real_queues - bucket_queues
|
177
|
+
|
178
|
+
result << bucket_queues
|
179
|
+
|
180
|
+
end
|
181
|
+
|
182
|
+
# insert the remaining queues at the position the default item was at (or last)
|
183
|
+
real_queues.shuffle! if default_fairly
|
184
|
+
result.insert(default_idx, real_queues)
|
185
|
+
result.flatten!
|
186
|
+
|
187
|
+
return result
|
188
|
+
end
|
189
|
+
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Qmore
|
2
|
+
class JobReserver
|
3
|
+
include Qmore::Attributes
|
4
|
+
attr_reader :queues
|
5
|
+
|
6
|
+
def initialize(queues)
|
7
|
+
@queues = queues
|
8
|
+
end
|
9
|
+
|
10
|
+
def description
|
11
|
+
@description ||= @queues.map(&:name).join(', ') + " (qmore)"
|
12
|
+
end
|
13
|
+
|
14
|
+
def reserve
|
15
|
+
realize_queues.each do |q|
|
16
|
+
job = q.pop
|
17
|
+
return job if job
|
18
|
+
end
|
19
|
+
|
20
|
+
nil
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def realize_queues
|
26
|
+
queue_names = @queues.collect(&:name)
|
27
|
+
real_queues = Qmore.client.queues.counts.collect {|h| h['name'] }
|
28
|
+
|
29
|
+
realized_queues = expand_queues(queue_names, real_queues)
|
30
|
+
realized_queues = prioritize_queues(get_priority_buckets, realized_queues)
|
31
|
+
realized_queues = realized_queues.collect {|q| Qmore.client.queues[q] }
|
32
|
+
realized_queues
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
data/lib/qmore/server.rb
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
require 'qmore'
|
2
|
+
|
3
|
+
module Qmore
|
4
|
+
module Server
|
5
|
+
|
6
|
+
Attr = Qmore::Attributes
|
7
|
+
|
8
|
+
def self.registered(app)
|
9
|
+
|
10
|
+
app.helpers do
|
11
|
+
|
12
|
+
def qmore_view(filename, options = {}, locals = {})
|
13
|
+
options = {:layout => true, :locals => { :title => filename.to_s.capitalize }}.merge(options)
|
14
|
+
dir = File.expand_path("../server/views/", __FILE__)
|
15
|
+
erb(File.read(File.join(dir, "#{filename}.erb")), options, locals)
|
16
|
+
end
|
17
|
+
|
18
|
+
alias :original_tabs :tabs
|
19
|
+
def tabs
|
20
|
+
qmore_tabs = [
|
21
|
+
{:name => 'DynamicQueues', :path => '/dynamicqueues'},
|
22
|
+
{:name => 'QueuePriority', :path => '/queuepriority'}
|
23
|
+
]
|
24
|
+
queue_tab_index = original_tabs.index {|t| t[:name] == 'Queues' }
|
25
|
+
original_tabs.insert(queue_tab_index + 1, *qmore_tabs)
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
|
30
|
+
#
|
31
|
+
# Dynamic queues
|
32
|
+
#
|
33
|
+
|
34
|
+
app.get "/dynamicqueues" do
|
35
|
+
@queues = []
|
36
|
+
real_queues = Qmore.client.queues.counts.collect {|q| q['name'] }
|
37
|
+
dqueues = Attr.get_dynamic_queues
|
38
|
+
dqueues.each do |k, v|
|
39
|
+
expanded = Attr.expand_queues(["@#{k}"], real_queues)
|
40
|
+
expanded = expanded.collect { |q| q.split(":").last }
|
41
|
+
view_data = {
|
42
|
+
'name' => k,
|
43
|
+
'value' => Array(v).join(", "),
|
44
|
+
'expanded' => expanded.join(", ")
|
45
|
+
}
|
46
|
+
@queues << view_data
|
47
|
+
end
|
48
|
+
|
49
|
+
@queues.sort! do |a, b|
|
50
|
+
an = a['name']
|
51
|
+
bn = b['name']
|
52
|
+
if an == 'default'
|
53
|
+
1
|
54
|
+
elsif bn == 'default'
|
55
|
+
-1
|
56
|
+
else
|
57
|
+
an <=> bn
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
qmore_view :dynamicqueues
|
62
|
+
end
|
63
|
+
|
64
|
+
app.post "/dynamicqueues" do
|
65
|
+
dynamic_queues = Array(params['queues'])
|
66
|
+
queues = {}
|
67
|
+
dynamic_queues.each do |queue|
|
68
|
+
key = queue['name']
|
69
|
+
values = queue['value'].to_s.split(',').collect { |q| q.gsub(/\s/, '') }
|
70
|
+
queues[key] = values
|
71
|
+
end
|
72
|
+
Attr.set_dynamic_queues(queues)
|
73
|
+
redirect "/dynamicqueues"
|
74
|
+
end
|
75
|
+
|
76
|
+
#
|
77
|
+
# Queue priorities
|
78
|
+
#
|
79
|
+
|
80
|
+
app.get "/queuepriority" do
|
81
|
+
@priorities = Attr.get_priority_buckets
|
82
|
+
qmore_view :priorities
|
83
|
+
end
|
84
|
+
|
85
|
+
app.post "/queuepriority" do
|
86
|
+
priorities = params['priorities']
|
87
|
+
Attr.set_priority_buckets priorities
|
88
|
+
redirect "/queuepriority"
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|