qless 0.9.2 → 0.9.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/Gemfile +2 -0
  2. data/README.md +42 -3
  3. data/Rakefile +26 -2
  4. data/{bin → exe}/qless-web +3 -2
  5. data/lib/qless.rb +55 -28
  6. data/lib/qless/config.rb +1 -3
  7. data/lib/qless/job.rb +127 -22
  8. data/lib/qless/job_reservers/round_robin.rb +3 -1
  9. data/lib/qless/job_reservers/shuffled_round_robin.rb +14 -0
  10. data/lib/qless/lua_script.rb +42 -0
  11. data/lib/qless/middleware/redis_reconnect.rb +24 -0
  12. data/lib/qless/middleware/retry_exceptions.rb +43 -0
  13. data/lib/qless/middleware/sentry.rb +70 -0
  14. data/lib/qless/qless-core/cancel.lua +89 -59
  15. data/lib/qless/qless-core/complete.lua +16 -1
  16. data/lib/qless/qless-core/config.lua +12 -0
  17. data/lib/qless/qless-core/deregister_workers.lua +12 -0
  18. data/lib/qless/qless-core/fail.lua +24 -14
  19. data/lib/qless/qless-core/heartbeat.lua +2 -1
  20. data/lib/qless/qless-core/pause.lua +18 -0
  21. data/lib/qless/qless-core/pop.lua +24 -3
  22. data/lib/qless/qless-core/put.lua +14 -1
  23. data/lib/qless/qless-core/qless-lib.lua +2354 -0
  24. data/lib/qless/qless-core/qless.lua +1862 -0
  25. data/lib/qless/qless-core/retry.lua +1 -1
  26. data/lib/qless/qless-core/unfail.lua +54 -0
  27. data/lib/qless/qless-core/unpause.lua +12 -0
  28. data/lib/qless/queue.rb +45 -21
  29. data/lib/qless/server.rb +38 -39
  30. data/lib/qless/server/static/css/docs.css +21 -1
  31. data/lib/qless/server/views/_job.erb +5 -5
  32. data/lib/qless/server/views/overview.erb +14 -9
  33. data/lib/qless/subscriber.rb +48 -0
  34. data/lib/qless/version.rb +1 -1
  35. data/lib/qless/wait_until.rb +19 -0
  36. data/lib/qless/worker.rb +243 -33
  37. metadata +49 -30
  38. data/bin/install_phantomjs +0 -7
  39. data/bin/qless-campfire +0 -106
  40. data/bin/qless-growl +0 -99
  41. data/lib/qless/lua.rb +0 -25
@@ -19,11 +19,13 @@ module Qless
19
19
  end
20
20
 
21
21
  def description
22
- @description ||= @queues.map(&:name).join(', ') + " (round robin)"
22
+ @description ||= @queues.map(&:name).join(', ') + " (#{self.class::TYPE_DESCRIPTION})"
23
23
  end
24
24
 
25
25
  private
26
26
 
27
+ TYPE_DESCRIPTION = "round robin"
28
+
27
29
  def next_queue
28
30
  @last_popped_queue_index = (@last_popped_queue_index + 1) % @num_queues
29
31
  @queues[@last_popped_queue_index]
@@ -0,0 +1,14 @@
1
+ require 'qless/job_reservers/round_robin'
2
+
3
+ module Qless
4
+ module JobReservers
5
+ class ShuffledRoundRobin < RoundRobin
6
+ def initialize(queues)
7
+ super(queues.shuffle)
8
+ end
9
+
10
+ TYPE_DESCRIPTION = "shuffled round robin"
11
+ end
12
+ end
13
+ end
14
+
@@ -0,0 +1,42 @@
1
+ require 'digest/sha1'
2
+
3
+ module Qless
4
+ class LuaScript
5
+ LUA_SCRIPT_DIR = File.expand_path("../qless-core/", __FILE__)
6
+
7
+ def initialize(name, redis)
8
+ @name = name
9
+ @redis = redis
10
+ @sha = Digest::SHA1.hexdigest(script_contents)
11
+ end
12
+
13
+ attr_reader :name, :redis, :sha
14
+
15
+ def reload()
16
+ @sha = @redis.script(:load, script_contents)
17
+ end
18
+
19
+ def call(keys, argv)
20
+ _call(keys, argv)
21
+ rescue
22
+ reload
23
+ _call(keys, argv)
24
+ end
25
+
26
+ private
27
+
28
+ if USING_LEGACY_REDIS_VERSION
29
+ def _call(keys, argv)
30
+ @redis.evalsha(@sha, keys.length, *(keys + argv))
31
+ end
32
+ else
33
+ def _call(keys, argv)
34
+ @redis.evalsha(@sha, keys: keys, argv: argv)
35
+ end
36
+ end
37
+
38
+ def script_contents
39
+ @script_contents ||= File.read(File.join(LUA_SCRIPT_DIR, "#{@name}.lua"))
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ module Qless
2
+ module Middleware
3
+ module RedisReconnect
4
+ def self.new(*redis_connections, &block)
5
+ Module.new do
6
+ define_singleton_method :to_s do
7
+ "Qless::Middleware::RedisReconnect"
8
+ end
9
+
10
+ block ||= lambda { |job| redis_connections }
11
+
12
+ define_method :around_perform do |job|
13
+ Array(block.call(job)).each do |redis|
14
+ redis.client.reconnect
15
+ end
16
+
17
+ super(job)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
@@ -0,0 +1,43 @@
1
+ module Qless
2
+ module Middleware
3
+ module RetryExceptions
4
+ def around_perform(job)
5
+ super
6
+ rescue *retryable_exception_classes => e
7
+ raise if job.retries_left <= 0
8
+
9
+ attempt_num = (job.original_retries - job.retries_left) + 1
10
+ job.retry(backoff_strategy.call(attempt_num))
11
+ end
12
+
13
+ def retryable_exception_classes
14
+ @retryable_exception_classes ||= []
15
+ end
16
+
17
+ def retry_on(*exception_classes)
18
+ retryable_exception_classes.push(*exception_classes)
19
+ end
20
+
21
+ NO_BACKOFF_STRATEGY = lambda { |num| 0 }
22
+
23
+ def use_backoff_strategy(strategy = nil, &block)
24
+ @backoff_strategy = strategy || block
25
+ end
26
+
27
+ def backoff_strategy
28
+ @backoff_strategy ||= NO_BACKOFF_STRATEGY
29
+ end
30
+
31
+ def exponential(base, options = {})
32
+ rand_fuzz = options.fetch(:rand_fuzz, 1)
33
+ lambda do |num|
34
+ base ** num + rand(rand_fuzz)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ # For backwards compatibility
41
+ RetryExceptions = Middleware::RetryExceptions
42
+ end
43
+
@@ -0,0 +1,70 @@
1
+ require 'raven'
2
+
3
+ module Qless
4
+ module Middleware
5
+ # This middleware logs errors to the sentry exception notification service:
6
+ # http://getsentry.com/
7
+ module Sentry
8
+ def around_perform(job)
9
+ super
10
+ rescue Exception => e
11
+ SentryLogger.new(e, job).log
12
+ raise
13
+ end
14
+
15
+ # Logs a single exception to Sentry, adding pertinent job info.
16
+ class SentryLogger
17
+ def initialize(exception, job)
18
+ @exception, @job = exception, job
19
+ end
20
+
21
+ def log
22
+ event = ::Raven::Event.capture_exception(@exception) do |evt|
23
+ evt.extra = { job: job_metadata }
24
+ end
25
+
26
+ safely_send event
27
+ end
28
+
29
+ private
30
+
31
+ def safely_send(event)
32
+ return unless event
33
+ ::Raven.send(event)
34
+ rescue
35
+ # We don't want to silence our errors when the Sentry server
36
+ # responds with an error. We'll still see the errors on the
37
+ # Qless Web UI.
38
+ end
39
+
40
+
41
+ def job_metadata
42
+ {
43
+ jid: @job.jid,
44
+ klass: @job.klass_name,
45
+ history: job_history,
46
+ data: @job.data,
47
+ queue: @job.queue_name,
48
+ worker: @job.worker_name,
49
+ tags: @job.tags,
50
+ priority: @job.priority
51
+ }
52
+ end
53
+
54
+ # We want to log formatted timestamps rather than integer timestamps
55
+ def job_history
56
+ @job.queue_history.map do |history_event|
57
+ history_event.each_with_object({}) do |(key, value), hash|
58
+ hash[key] = if value.is_a?(Time)
59
+ value.iso8601
60
+ else
61
+ value
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+
@@ -9,63 +9,93 @@
9
9
 
10
10
  if #KEYS > 0 then error('Cancel(): No Keys should be provided') end
11
11
 
12
- local jid = assert(ARGV[1], 'Cancel(): Arg "jid" missing.')
13
-
14
- -- Find any stage it's associated with and remove its from that stage
15
- local state, queue, failure, worker = unpack(redis.call('hmget', 'ql:j:' .. jid, 'state', 'queue', 'failure', 'worker'))
16
-
17
- if state == 'complete' then
18
- return false
19
- else
20
- -- If this job has dependents, then we should probably fail
21
- if redis.call('scard', 'ql:j:' .. jid .. '-dependents') > 0 then
22
- error('Cancel(): ' .. jid .. ' has un-canceled jobs that depend on it')
23
- end
24
-
25
- -- Remove this job from whatever worker has it, if any
26
- if worker then
27
- redis.call('zrem', 'ql:w:' .. worker .. ':jobs', jid)
28
- end
29
-
30
- -- Remove it from that queue
31
- if queue then
32
- redis.call('zrem', 'ql:q:' .. queue .. '-work', jid)
33
- redis.call('zrem', 'ql:q:' .. queue .. '-locks', jid)
34
- redis.call('zrem', 'ql:q:' .. queue .. '-scheduled', jid)
35
- redis.call('zrem', 'ql:q:' .. queue .. '-depends', jid)
36
- end
37
-
38
- -- We should probably go through all our dependencies and remove ourselves
39
- -- from the list of dependents
40
- for i, j in ipairs(redis.call('smembers', 'ql:j:' .. jid .. '-dependencies')) do
41
- redis.call('srem', 'ql:j:' .. j .. '-dependents', jid)
42
- end
43
-
44
- -- Delete any notion of dependencies it has
45
- redis.call('del', 'ql:j:' .. jid .. '-dependencies')
46
-
47
- -- If we're in the failed state, remove all of our data
48
- if state == 'failed' then
49
- failure = cjson.decode(failure)
50
- -- We need to make this remove it from the failed queues
51
- redis.call('lrem', 'ql:f:' .. failure.group, 0, jid)
52
- if redis.call('llen', 'ql:f:' .. failure.group) == 0 then
53
- redis.call('srem', 'ql:failures', failure.group)
54
- end
55
- end
56
-
57
- -- Remove it as a job that's tagged with this particular tag
58
- local tags = cjson.decode(redis.call('hget', 'ql:j:' .. jid, 'tags') or '{}')
59
- for i, tag in ipairs(tags) do
60
- redis.call('zrem', 'ql:t:' .. tag, jid)
61
- redis.call('zincrby', 'ql:tags', -1, tag)
62
- end
63
-
64
- -- If the job was being tracked, we should notify
65
- if redis.call('zscore', 'ql:tracked', jid) ~= false then
66
- redis.call('publish', 'canceled', jid)
67
- end
68
-
69
- -- Just go ahead and delete our data
70
- redis.call('del', 'ql:j:' .. jid)
12
+ local function cancel(jid, jid_set)
13
+ if not jid_set[jid] then
14
+ error('Cancel(): ' .. jid .. ' is a dependency of one of the jobs but is not in the provided jid set')
15
+ end
16
+
17
+ -- Find any stage it's associated with and remove its from that stage
18
+ local state, queue, failure, worker = unpack(redis.call('hmget', 'ql:j:' .. jid, 'state', 'queue', 'failure', 'worker'))
19
+
20
+ if state == 'complete' then
21
+ return false
22
+ else
23
+ -- If this job has dependents, then we should probably fail
24
+ local dependents = redis.call('smembers', 'ql:j:' .. jid .. '-dependents')
25
+ for _, dependent_jid in ipairs(dependents) do
26
+ cancel(dependent_jid, jid_set)
27
+ end
28
+
29
+ -- Send a message out on the appropriate channels
30
+ local encoded = cjson.encode({
31
+ jid = jid,
32
+ worker = worker,
33
+ event = 'canceled',
34
+ queue = queue
35
+ })
36
+ redis.call('publish', 'ql:log', encoded)
37
+
38
+ -- Remove this job from whatever worker has it, if any
39
+ if worker then
40
+ redis.call('zrem', 'ql:w:' .. worker .. ':jobs', jid)
41
+ -- If necessary, send a message to the appropriate worker, too
42
+ redis.call('publish', 'ql:w:' .. worker, encoded)
43
+ end
44
+
45
+ -- Remove it from that queue
46
+ if queue then
47
+ redis.call('zrem', 'ql:q:' .. queue .. '-work', jid)
48
+ redis.call('zrem', 'ql:q:' .. queue .. '-locks', jid)
49
+ redis.call('zrem', 'ql:q:' .. queue .. '-scheduled', jid)
50
+ redis.call('zrem', 'ql:q:' .. queue .. '-depends', jid)
51
+ end
52
+
53
+ -- We should probably go through all our dependencies and remove ourselves
54
+ -- from the list of dependents
55
+ for i, j in ipairs(redis.call('smembers', 'ql:j:' .. jid .. '-dependencies')) do
56
+ redis.call('srem', 'ql:j:' .. j .. '-dependents', jid)
57
+ end
58
+
59
+ -- Delete any notion of dependencies it has
60
+ redis.call('del', 'ql:j:' .. jid .. '-dependencies')
61
+
62
+ -- If we're in the failed state, remove all of our data
63
+ if state == 'failed' then
64
+ failure = cjson.decode(failure)
65
+ -- We need to make this remove it from the failed queues
66
+ redis.call('lrem', 'ql:f:' .. failure.group, 0, jid)
67
+ if redis.call('llen', 'ql:f:' .. failure.group) == 0 then
68
+ redis.call('srem', 'ql:failures', failure.group)
69
+ end
70
+ end
71
+
72
+ -- Remove it as a job that's tagged with this particular tag
73
+ local tags = cjson.decode(redis.call('hget', 'ql:j:' .. jid, 'tags') or '{}')
74
+ for i, tag in ipairs(tags) do
75
+ redis.call('zrem', 'ql:t:' .. tag, jid)
76
+ redis.call('zincrby', 'ql:tags', -1, tag)
77
+ end
78
+
79
+ -- If the job was being tracked, we should notify
80
+ if redis.call('zscore', 'ql:tracked', jid) ~= false then
81
+ redis.call('publish', 'canceled', jid)
82
+ end
83
+
84
+ -- Just go ahead and delete our data
85
+ redis.call('del', 'ql:j:' .. jid)
86
+ end
87
+ end
88
+
89
+ -- Taken from: http://www.lua.org/pil/11.5.html
90
+ local function to_set(list)
91
+ local set = {}
92
+ for _, l in ipairs(list) do set[l] = true end
93
+ return set
94
+ end
95
+
96
+ local jids = assert(ARGV, 'Cancel(): Arg "jid" missing.')
97
+ local jid_set = to_set(jids)
98
+
99
+ for _, jid in ipairs(jids) do
100
+ cancel(jid, jid_set)
71
101
  end
@@ -118,12 +118,20 @@ if redis.call('zscore', 'ql:tracked', jid) ~= false then
118
118
  end
119
119
 
120
120
  if nextq then
121
+ -- Send a message out to log
122
+ redis.call('publish', 'ql:log', cjson.encode({
123
+ jid = jid,
124
+ event = 'advanced',
125
+ queue = queue,
126
+ to = nextq
127
+ }))
128
+
121
129
  -- Enqueue the job
122
130
  table.insert(history, {
123
131
  q = nextq,
124
132
  put = math.floor(now)
125
133
  })
126
-
134
+
127
135
  -- We're going to make sure that this queue is in the
128
136
  -- set of known queues
129
137
  if redis.call('zscore', 'ql:queues', nextq) == false then
@@ -158,6 +166,13 @@ if nextq then
158
166
  end
159
167
  end
160
168
  else
169
+ -- Send a message out to log
170
+ redis.call('publish', 'ql:log', cjson.encode({
171
+ jid = jid,
172
+ event = 'completed',
173
+ queue = queue
174
+ }))
175
+
161
176
  redis.call('hmset', 'ql:j:' .. jid, 'state', 'complete', 'worker', '', 'failure', '{}',
162
177
  'queue', '', 'expires', 0, 'history', cjson.encode(history), 'remaining', tonumber(retries))
163
178
 
@@ -33,9 +33,21 @@ if command == 'get' then
33
33
  elseif command == 'set' then
34
34
  local option = assert(ARGV[2], 'Config(): Arg "option" missing')
35
35
  local value = assert(ARGV[3], 'Config(): Arg "value" missing')
36
+ -- Send out a log message
37
+ redis.call('publish', 'ql:log', cjson.encode({
38
+ event = 'config_set',
39
+ option = option
40
+ }))
41
+
36
42
  redis.call('hset', 'ql:config', option, value)
37
43
  elseif command == 'unset' then
38
44
  local option = assert(ARGV[2], 'Config(): Arg "option" missing')
45
+ -- Send out a log message
46
+ redis.call('publish', 'ql:log', cjson.encode({
47
+ event = 'config_unset',
48
+ option = option
49
+ }))
50
+
39
51
  redis.call('hdel', 'ql:config', option)
40
52
  else
41
53
  error('Config(): Unrecognized command ' .. command)
@@ -0,0 +1,12 @@
1
+ -- DeregisterWorkers(0, worker)
2
+ -- This script takes the name of a worker(s) on removes it/them
3
+ -- from the ql:workers set.
4
+ --
5
+ -- Args: The list of workers to deregister.
6
+
7
+ if #KEYS > 0 then error('DeregisterWorkers(): No Keys should be provided') end
8
+ if #ARGV < 1 then error('DeregisterWorkers(): Must provide at least one worker to deregister') end
9
+
10
+ local key = 'ql:workers'
11
+
12
+ redis.call('zrem', key, unpack(ARGV))
@@ -1,20 +1,21 @@
1
1
  -- Fail(0, jid, worker, group, message, now, [data])
2
2
  -- -------------------------------------------------
3
- -- Mark the particular job as failed, with the provided group, and a more specific
4
- -- message. By `group`, we mean some phrase that might be one of several categorical
5
- -- modes of failure. The `message` is something more job-specific, like perhaps
6
- -- a traceback.
3
+ -- Mark the particular job as failed, with the provided group, and a more
4
+ -- specific message. By `group`, we mean some phrase that might be one of
5
+ -- several categorical modes of failure. The `message` is something more
6
+ -- job-specific, like perhaps a traceback.
7
7
  --
8
- -- This method should __not__ be used to note that a job has been dropped or has
9
- -- failed in a transient way. This method __should__ be used to note that a job has
10
- -- something really wrong with it that must be remedied.
8
+ -- This method should __not__ be used to note that a job has been dropped or
9
+ -- has failed in a transient way. This method __should__ be used to note that
10
+ -- a job has something really wrong with it that must be remedied.
11
11
  --
12
- -- The motivation behind the `group` is so that similar errors can be grouped together.
13
- -- Optionally, updated data can be provided for the job. A job in any state can be
14
- -- marked as failed. If it has been given to a worker as a job, then its subsequent
15
- -- requests to heartbeat or complete that job will fail. Failed jobs are kept until
16
- -- they are canceled or completed. __Returns__ the id of the failed job if successful,
17
- -- or `False` on failure.
12
+ -- The motivation behind the `group` is so that similar errors can be grouped
13
+ -- together. Optionally, updated data can be provided for the job. A job in
14
+ -- any state can be marked as failed. If it has been given to a worker as a
15
+ -- job, then its subsequent requests to heartbeat or complete that job will
16
+ -- fail. Failed jobs are kept until they are canceled or completed.
17
+ --
18
+ -- __Returns__ the id of the failed job if successful, or `False` on failure.
18
19
  --
19
20
  -- Args:
20
21
  -- 1) jid
@@ -49,6 +50,15 @@ if state ~= 'running' then
49
50
  return false
50
51
  end
51
52
 
53
+ -- Send out a log message
54
+ redis.call('publish', 'ql:log', cjson.encode({
55
+ jid = jid,
56
+ event = 'failed',
57
+ worker = worker,
58
+ group = group,
59
+ message = message
60
+ }))
61
+
52
62
  if redis.call('zscore', 'ql:tracked', jid) ~= false then
53
63
  redis.call('publish', 'failed', jid)
54
64
  end
@@ -104,4 +114,4 @@ redis.call('lpush', 'ql:f:' .. group, jid)
104
114
  -- Here is where we'd intcrement stats about the particular stage
105
115
  -- and possibly the workers
106
116
 
107
- return jid
117
+ return jid