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
data/Gemfile CHANGED
@@ -6,3 +6,5 @@ gemspec
6
6
  group :development do
7
7
  gem 'debugger', :platform => :mri
8
8
  end
9
+
10
+ gem 'thin' # needed by qless-web binary
data/README.md CHANGED
@@ -20,7 +20,7 @@ put in. So if a worker is working on a job, and you move it, the worker's reques
20
20
  complete the job will be ignored.
21
21
 
22
22
  A job can be `canceled`, which means it disappears into the ether, and we'll never
23
- pay it any mind every again. A job can be `dropped`, which is when a worker fails
23
+ pay it any mind ever again. A job can be `dropped`, which is when a worker fails
24
24
  to heartbeat or complete the job in a timely fashion, or a job can be `failed`,
25
25
  which is when a host recognizes some systematically problematic state about the
26
26
  job. A worker should only fail a job if the error is likely not a transient one;
@@ -189,17 +189,20 @@ Then run the `qless:work` rake task:
189
189
  rake qless:work
190
190
  ```
191
191
 
192
- The following signals are supported:
192
+ The following signals are supported in the parent process:
193
193
 
194
194
  * TERM: Shutdown immediately, stop processing jobs.
195
195
  * INT: Shutdown immediately, stop processing jobs.
196
196
  * QUIT: Shutdown after the current job has finished processing.
197
197
  * USR1: Kill the forked child immediately, continue processing jobs.
198
- * USR2: Don't process any new jobs
198
+ * USR2: Don't process any new jobs, and dump the current backtrace.
199
199
  * CONT: Start processing jobs again after a USR2
200
200
 
201
201
  You should send these to the master process, not the child.
202
202
 
203
+ The child process supports the `USR2` signal, whch causes it to
204
+ dump its current backtrace.
205
+
203
206
  Workers also support middleware modules that can be used to inject
204
207
  logic before, after or around the processing of a single job in
205
208
  the child process. This can be useful, for example, when you need to
@@ -228,6 +231,42 @@ Qless::Worker.class_eval do
228
231
  end
229
232
  ```
230
233
 
234
+ Per-Job Middlewares
235
+ ===================
236
+
237
+ Qless also supports middleware on a per-job basis, when you have some
238
+ orthogonal logic to run in the context of some (but not all) jobs.
239
+
240
+ Per-job middlewares are defined the same as worker middlewares:
241
+
242
+ ``` ruby
243
+ module ReEstablishDBConnection
244
+ def around_perform(job)
245
+ MyORM.establish_connection
246
+ super
247
+ end
248
+ end
249
+ ```
250
+
251
+ To add them to a job class, you first have to make your job class
252
+ middleware-enabled by extending it with
253
+ `Qless::Job::SupportsMiddleware`, then extend your middleware
254
+ modules:
255
+
256
+ ``` ruby
257
+ class MyJobClass
258
+ extend Qless::Job::SupportsMiddleware
259
+ extend ReEstablishDBConnection
260
+ extend MyOtherAwesomeMiddleware
261
+
262
+ def self.perform(job)
263
+ end
264
+ end
265
+ ```
266
+
267
+ Note that `Qless::Job::SupportsMiddleware` must be extended onto your
268
+ job class _before_ any other middleware modules.
269
+
231
270
  Web Interface
232
271
  =============
233
272
 
data/Rakefile CHANGED
@@ -11,7 +11,8 @@ RSpec::Core::RakeTask.new(:spec) do |t|
11
11
  end
12
12
 
13
13
  # TODO: bump this up as test coverage increases. It was 90.29 when I last updated it on 2012-05-21.
14
- min_coverage_threshold = 90.0
14
+ # On travis where we skip JS tests, it's at 83.9 on 2013-01-15
15
+ min_coverage_threshold = 83.5
15
16
  desc "Checks the spec coverage and fails if it is less than #{min_coverage_threshold}%"
16
17
  task :check_coverage do
17
18
  percent = File.read("./coverage/coverage_percent.txt").to_f
@@ -24,5 +25,28 @@ end
24
25
 
25
26
  task default: [:spec, :check_coverage]
26
27
 
27
-
28
28
  require 'qless/tasks'
29
+
30
+ namespace :qless do
31
+ task :setup do
32
+ require 'qless'
33
+ queue = Qless::Client.new.queues["example"]
34
+ queue.client.redis.flushdb
35
+
36
+ ENV['QUEUES'] = queue.name
37
+ ENV['VVERBOSE'] = '1'
38
+
39
+ class ExampleJob
40
+ def self.perform(job)
41
+ sleep_time = job.data.fetch("sleep")
42
+ print "Sleeping for #{sleep_time}..."
43
+ sleep sleep_time
44
+ puts "done"
45
+ end
46
+ end
47
+
48
+ 20.times do |i|
49
+ queue.put(ExampleJob, sleep: i)
50
+ end
51
+ end
52
+ end
@@ -9,8 +9,9 @@ rescue LoadError
9
9
  end
10
10
 
11
11
  require 'qless/server'
12
+ client = Qless::Client.new
12
13
 
13
- Vegas::Runner.new(Qless::Server, 'qless-web', {
14
+ Vegas::Runner.new(Qless::Server.new(client), 'qless-web', {
14
15
  :before_run => lambda {|v|
15
16
  path = (ENV['RESQUECONFIG'] || v.args.first)
16
17
  load path.to_s.strip if path
@@ -20,4 +21,4 @@ Vegas::Runner.new(Qless::Server, 'qless-web', {
20
21
  # runner.logger.info "Using Redis connection '#{redis_conf}'"
21
22
  # Resque.redis = redis_conf
22
23
  # }
23
- end
24
+ end
@@ -3,15 +3,28 @@ require "redis"
3
3
  require "json"
4
4
  require "securerandom"
5
5
 
6
+ module Qless
7
+ # Define our error base class before requiring the other
8
+ # files so they can define subclasses.
9
+ Error = Class.new(StandardError)
10
+
11
+ # to maintain backwards compatibility with v2.x of that gem we need this constant because:
12
+ # * (lua.rb) the #evalsha method signature changed between v2.x and v3.x of the redis ruby gem
13
+ # * (worker.rb) in v3.x you have to reconnect to the redis server after forking the process
14
+ USING_LEGACY_REDIS_VERSION = ::Redis::VERSION.to_f < 3.0
15
+ end
16
+
6
17
  require "qless/version"
7
18
  require "qless/config"
8
19
  require "qless/queue"
9
20
  require "qless/job"
10
- require "qless/lua"
21
+ require "qless/lua_script"
11
22
 
12
23
  module Qless
13
24
  extend self
14
25
 
26
+ UnsupportedRedisVersionError = Class.new(Error)
27
+
15
28
  def generate_jid
16
29
  SecureRandom.uuid.gsub('-', '')
17
30
  end
@@ -27,27 +40,25 @@ module Qless
27
40
  @worker_name ||= [Socket.gethostname, Process.pid.to_s].join('-')
28
41
  end
29
42
 
30
- class UnsupportedRedisVersionError < StandardError; end
31
-
32
43
  class ClientJobs
33
44
  def initialize(client)
34
45
  @client = client
35
46
  end
36
-
47
+
37
48
  def complete(offset=0, count=25)
38
49
  @client._jobs.call([], ['complete', offset, count])
39
50
  end
40
-
51
+
41
52
  def tracked
42
53
  results = JSON.parse(@client._track.call([], []))
43
54
  results['jobs'] = results['jobs'].map { |j| Job.new(@client, j) }
44
55
  results
45
56
  end
46
-
57
+
47
58
  def tagged(tag, offset=0, count=25)
48
59
  JSON.parse(@client._tag.call([], ['get', tag, offset, count]))
49
60
  end
50
-
61
+
51
62
  def failed(t=nil, start=0, limit=25)
52
63
  if not t
53
64
  JSON.parse(@client._failed.call([], []))
@@ -57,7 +68,7 @@ module Qless
57
68
  results
58
69
  end
59
70
  end
60
-
71
+
61
72
  def [](id)
62
73
  results = @client._get.call([], [id])
63
74
  if results.nil?
@@ -70,41 +81,41 @@ module Qless
70
81
  Job.new(@client, JSON.parse(results))
71
82
  end
72
83
  end
73
-
84
+
74
85
  class ClientWorkers
75
86
  def initialize(client)
76
87
  @client = client
77
88
  end
78
-
89
+
79
90
  def counts
80
91
  JSON.parse(@client._workers.call([], [Time.now.to_i]))
81
92
  end
82
-
93
+
83
94
  def [](name)
84
95
  JSON.parse(@client._workers.call([], [Time.now.to_i, name]))
85
96
  end
86
97
  end
87
-
98
+
88
99
  class ClientQueues
89
100
  def initialize(client)
90
101
  @client = client
91
102
  end
92
-
103
+
93
104
  def counts
94
105
  JSON.parse(@client._queues.call([], [Time.now.to_i]))
95
106
  end
96
-
107
+
97
108
  def [](name)
98
109
  Queue.new(name, @client)
99
110
  end
100
111
  end
101
-
112
+
102
113
  class ClientEvents
103
114
  def initialize(redis)
104
115
  @redis = redis
105
116
  @actions = Hash.new()
106
117
  end
107
-
118
+
108
119
  def canceled(&block) ; @actions[:canceled ] = block; end
109
120
  def completed(&block); @actions[:completed] = block; end
110
121
  def failed(&block) ; @actions[:failed ] = block; end
@@ -113,7 +124,7 @@ module Qless
113
124
  def put(&block) ; @actions[:put ] = block; end
114
125
  def track(&block) ; @actions[:track ] = block; end
115
126
  def untrack(&block) ; @actions[:untrack ] = block; end
116
-
127
+
117
128
  def listen
118
129
  yield(self) if block_given?
119
130
  @redis.subscribe(:canceled, :completed, :failed, :popped, :stalled, :put, :track, :untrack) do |on|
@@ -125,19 +136,20 @@ module Qless
125
136
  end
126
137
  end
127
138
  end
128
-
139
+
129
140
  def stop
130
141
  @redis.unsubscribe
131
142
  end
132
143
  end
133
-
144
+
134
145
  class Client
135
146
  # Lua scripts
136
147
  attr_reader :_cancel, :_config, :_complete, :_fail, :_failed, :_get, :_heartbeat, :_jobs, :_peek, :_pop
137
148
  attr_reader :_priority, :_put, :_queues, :_recur, :_retry, :_stats, :_tag, :_track, :_workers, :_depends
149
+ attr_reader :_pause, :_unpause, :_deregister_workers
138
150
  # A real object
139
151
  attr_reader :config, :redis, :jobs, :queues, :workers
140
-
152
+
141
153
  def initialize(options = {})
142
154
  # This is the redis instance we're connected to
143
155
  @redis = options[:redis] || Redis.connect(options) # use connect so REDIS_URL will be honored
@@ -145,10 +157,11 @@ module Qless
145
157
  assert_minimum_redis_version("2.5.5")
146
158
  @config = Config.new(self)
147
159
  ['cancel', 'config', 'complete', 'depends', 'fail', 'failed', 'get', 'heartbeat', 'jobs', 'peek', 'pop',
148
- 'priority', 'put', 'queues', 'recur', 'retry', 'stats', 'tag', 'track', 'workers'].each do |f|
149
- self.instance_variable_set("@_#{f}", Lua.new(f, @redis))
160
+ 'priority', 'put', 'queues', 'recur', 'retry', 'stats', 'tag', 'track', 'workers', 'pause', 'unpause',
161
+ 'deregister_workers'].each do |f|
162
+ self.instance_variable_set("@_#{f}", Qless::LuaScript.new(f, @redis))
150
163
  end
151
-
164
+
152
165
  @jobs = ClientJobs.new(self)
153
166
  @queues = ClientQueues.new(self)
154
167
  @workers = ClientWorkers.new(self)
@@ -157,29 +170,43 @@ module Qless
157
170
  def inspect
158
171
  "<Qless::Client #{@options} >"
159
172
  end
160
-
173
+
161
174
  def events
162
175
  # Events needs its own redis instance of the same configuration, because
163
176
  # once it's subscribed, we can only use pub-sub-like commands. This way,
164
177
  # we still have access to the client in the normal case
165
178
  @events ||= ClientEvents.new(Redis.connect(@options))
166
179
  end
167
-
180
+
168
181
  def track(jid)
169
182
  @_track.call([], ['track', jid, Time.now.to_i])
170
183
  end
171
-
184
+
172
185
  def untrack(jid)
173
186
  @_track.call([], ['untrack', jid, Time.now.to_i])
174
187
  end
175
-
188
+
176
189
  def tags(offset=0, count=100)
177
190
  JSON.parse(@_tag.call([], ['top', offset, count]))
178
191
  end
192
+
193
+ def deregister_workers(*worker_names)
194
+ _deregister_workers.call([], worker_names)
195
+ end
196
+
197
+ def bulk_cancel(jids)
198
+ @_cancel.call([], jids)
199
+ end
200
+
201
+ def new_redis_connection
202
+ ::Redis.new(url: redis.id)
203
+ end
204
+
179
205
  private
180
206
 
181
207
  def assert_minimum_redis_version(version)
182
- redis_version = @redis.info.fetch("redis_version")
208
+ # remove the "-pre2" from "2.6.8-pre2"
209
+ redis_version = @redis.info.fetch("redis_version").split('-').first
183
210
  return if Gem::Version.new(redis_version) >= Gem::Version.new(version)
184
211
 
185
212
  raise UnsupportedRedisVersionError,
@@ -1,5 +1,3 @@
1
- require "qless/lua"
2
- require "redis"
3
1
  require "json"
4
2
 
5
3
  module Qless
@@ -28,4 +26,4 @@ module Qless
28
26
  @client._config.call([], ['unset', option])
29
27
  end
30
28
  end
31
- end
29
+ end
@@ -1,18 +1,19 @@
1
1
  require "qless"
2
2
  require "qless/queue"
3
- require "qless/lua"
4
3
  require "redis"
5
4
  require "json"
6
5
 
7
6
  module Qless
8
7
  class BaseJob
8
+ attr_reader :client
9
+
9
10
  def initialize(client, jid)
10
11
  @client = client
11
12
  @jid = jid
12
13
  end
13
14
 
14
15
  def klass
15
- @klass ||= @klass_name.split('::').inject(Kernel) { |context, name| context.const_get(name) }
16
+ @klass ||= @klass_name.split('::').inject(Object) { |context, name| context.const_get(name) }
16
17
  end
17
18
 
18
19
  def queue
@@ -21,12 +22,30 @@ module Qless
21
22
  end
22
23
 
23
24
  class Job < BaseJob
24
- attr_reader :jid, :expires_at, :state, :queue_name, :history, :worker_name, :failure, :klass_name, :tracked, :dependencies, :dependents
25
- attr_reader :original_retries, :retries_left
25
+ attr_reader :jid, :expires_at, :state, :queue_name, :worker_name, :failure, :klass_name, :tracked, :dependencies, :dependents
26
+ attr_reader :original_retries, :retries_left, :raw_queue_history
26
27
  attr_accessor :data, :priority, :tags
27
28
 
29
+ MiddlewareMisconfiguredError = Class.new(StandardError)
30
+
31
+ module SupportsMiddleware
32
+ def around_perform(job)
33
+ perform(job)
34
+ end
35
+ end
36
+
28
37
  def perform
29
- klass.perform(self)
38
+ middlewares = Job.middlewares_on(klass)
39
+
40
+ if middlewares.last == SupportsMiddleware
41
+ klass.around_perform(self)
42
+ elsif middlewares.any?
43
+ raise MiddlewareMisconfiguredError, "The middleware chain for #{klass} " +
44
+ "(#{middlewares.inspect}) is misconfigured. Qless::Job::SupportsMiddleware " +
45
+ "must be extended onto your job class first if you want to use any middleware."
46
+ else
47
+ klass.perform(self)
48
+ end
30
49
  end
31
50
 
32
51
  def self.build(client, klass, attributes = {})
@@ -49,29 +68,38 @@ module Qless
49
68
  "dependents" => []
50
69
  }
51
70
  attributes = defaults.merge(Qless.stringify_hash_keys(attributes))
52
- attributes["data"] = JSON.load(JSON.dump attributes["data"])
71
+ attributes["data"] = JSON.parse(JSON.dump attributes["data"])
53
72
  new(client, attributes)
54
73
  end
55
74
 
75
+ def self.middlewares_on(job_klass)
76
+ job_klass.singleton_class.ancestors.select do |ancestor|
77
+ ancestor.method_defined?(:around_perform)
78
+ end
79
+ end
80
+
56
81
  def initialize(client, atts)
57
82
  super(client, atts.fetch('jid'))
58
83
  %w{jid data priority tags state tracked
59
- failure history dependencies dependents}.each do |att|
84
+ failure dependencies dependents}.each do |att|
60
85
  self.instance_variable_set("@#{att}".to_sym, atts.fetch(att))
61
86
  end
62
87
 
63
- @expires_at = atts.fetch('expires')
64
- @klass_name = atts.fetch('klass')
65
- @queue_name = atts.fetch('queue')
66
- @worker_name = atts.fetch('worker')
67
- @original_retries = atts.fetch('retries')
68
- @retries_left = atts.fetch('remaining')
88
+ @expires_at = atts.fetch('expires')
89
+ @klass_name = atts.fetch('klass')
90
+ @queue_name = atts.fetch('queue')
91
+ @worker_name = atts.fetch('worker')
92
+ @original_retries = atts.fetch('retries')
93
+ @retries_left = atts.fetch('remaining')
94
+ @raw_queue_history = atts.fetch('history')
69
95
 
70
96
  # This is a silly side-effect of Lua doing JSON parsing
71
97
  @tags = [] if @tags == {}
72
98
  @dependents = [] if @dependents == {}
73
99
  @dependencies = [] if @dependencies == {}
74
100
  @state_changed = false
101
+ @before_callbacks = Hash.new { |h, k| h[k] = [] }
102
+ @after_callbacks = Hash.new { |h, k| h[k] = [] }
75
103
  end
76
104
 
77
105
  def priority=(priority)
@@ -93,7 +121,7 @@ module Qless
93
121
  end
94
122
 
95
123
  def description
96
- "#{@jid} (#{@klass_name} / #{@queue_name})"
124
+ "#{@klass_name} (#{@jid} / #{@queue_name} / #{@state})"
97
125
  end
98
126
 
99
127
  def inspect
@@ -104,9 +132,57 @@ module Qless
104
132
  @expires_at - Time.now.to_f
105
133
  end
106
134
 
135
+ def reconnect_to_redis
136
+ @client.redis.client.reconnect
137
+ end
138
+
139
+ def history
140
+ warn "WARNING: Qless::Job#history is deprecated; use Qless::Job#raw_queue_history instead" +
141
+ "; called from:\n#{caller.first}\n"
142
+ raw_queue_history
143
+ end
144
+
145
+ def queue_history
146
+ @queue_history ||= @raw_queue_history.map do |history_event|
147
+ history_event.each_with_object({}) do |(key, value), hash|
148
+ # The only Numeric (Integer or Float) values we get in the history are timestamps
149
+ hash[key] = if value.is_a?(Numeric)
150
+ Time.at(value).utc
151
+ else
152
+ value
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ def initially_put_at
159
+ @initially_put_at ||= history_timestamp('put', :min)
160
+ end
161
+
162
+ def to_hash
163
+ {
164
+ jid: jid,
165
+ expires_at: expires_at,
166
+ state: state,
167
+ queue_name: queue_name,
168
+ history: raw_queue_history,
169
+ worker_name: worker_name,
170
+ failure: failure,
171
+ klass_name: klass_name,
172
+ tracked: tracked,
173
+ dependencies: dependencies,
174
+ dependents: dependents,
175
+ original_retries: original_retries,
176
+ retries_left: retries_left,
177
+ data: data,
178
+ priority: priority,
179
+ tags: tags
180
+ }
181
+ end
182
+
107
183
  # Move this from it's current queue into another
108
184
  def move(queue)
109
- note_state_change do
185
+ note_state_change :move do
110
186
  @client._put.call([queue], [
111
187
  @jid, @klass_name, JSON.generate(@data), Time.now.to_f, 0
112
188
  ])
@@ -115,7 +191,7 @@ module Qless
115
191
 
116
192
  # Fail a job
117
193
  def fail(group, message)
118
- note_state_change do
194
+ note_state_change :fail do
119
195
  @client._fail.call([], [
120
196
  @jid,
121
197
  @worker_name,
@@ -134,13 +210,15 @@ module Qless
134
210
  JSON.generate(@data)]) || false
135
211
  end
136
212
 
213
+ CantCompleteError = Class.new(Qless::Error)
214
+
137
215
  # Complete a job
138
216
  # Options include
139
217
  # => next (String) the next queue
140
218
  # => delay (int) how long to delay it in the next queue
141
219
  def complete(nxt=nil, options={})
142
- response = note_state_change do
143
- if nxt.nil?
220
+ note_state_change :complete do
221
+ response = if nxt.nil?
144
222
  @client._complete.call([], [
145
223
  @jid, @worker_name, @queue_name, Time.now.to_f, JSON.generate(@data)])
146
224
  else
@@ -148,8 +226,19 @@ module Qless
148
226
  @jid, @worker_name, @queue_name, Time.now.to_f, JSON.generate(@data), 'next', nxt, 'delay',
149
227
  options.fetch(:delay, 0), 'depends', JSON.generate(options.fetch(:depends, []))])
150
228
  end
229
+
230
+ if response
231
+ response
232
+ else
233
+ description = if reloaded_instance = @client.jobs[@jid]
234
+ reloaded_instance.description
235
+ else
236
+ self.description + " -- can't be reloaded"
237
+ end
238
+
239
+ raise CantCompleteError, "Failed to complete #{description}"
240
+ end
151
241
  end
152
- response.nil? ? false : response
153
242
  end
154
243
 
155
244
  def state_changed?
@@ -157,7 +246,7 @@ module Qless
157
246
  end
158
247
 
159
248
  def cancel
160
- note_state_change do
249
+ note_state_change :cancel do
161
250
  @client._cancel.call([], [@jid])
162
251
  end
163
252
  end
@@ -179,7 +268,7 @@ module Qless
179
268
  end
180
269
 
181
270
  def retry(delay=0)
182
- note_state_change do
271
+ note_state_change :retry do
183
272
  results = @client._retry.call([], [@jid, @queue_name, @worker_name, Time.now.to_f, delay])
184
273
  results.nil? ? false : results
185
274
  end
@@ -193,13 +282,29 @@ module Qless
193
282
  !!@client._depends.call([], [@jid, 'off'] + jids)
194
283
  end
195
284
 
285
+ [:fail, :complete, :cancel, :move, :retry].each do |event|
286
+ define_method :"before_#{event}" do |&block|
287
+ @before_callbacks[event] << block
288
+ end
289
+
290
+ define_method :"after_#{event}" do |&block|
291
+ @after_callbacks[event].unshift block
292
+ end
293
+ end
294
+
196
295
  private
197
296
 
198
- def note_state_change
297
+ def note_state_change(event)
298
+ @before_callbacks[event].each { |blk| blk.call(self) }
199
299
  result = yield
200
300
  @state_changed = true
301
+ @after_callbacks[event].each { |blk| blk.call(self) }
201
302
  result
202
303
  end
304
+
305
+ def history_timestamp(name, selector)
306
+ queue_history.map { |q| q[name] }.compact.send(selector)
307
+ end
203
308
  end
204
309
 
205
310
  class RecurringJob < BaseJob