qmore 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Build Status](https://secure.travis-ci.org/wr0ngway/qmore.png)](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
|