qless 0.9.2 → 0.9.3

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.
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