resque-status 0.2.4 → 0.3.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.
@@ -0,0 +1,213 @@
1
+ require 'resque/plugins/status/hash'
2
+
3
+ module Resque
4
+ module Plugins
5
+
6
+ # Resque::Plugins::Status is a module you're jobs will include.
7
+ # It provides helper methods for updating the status/etc from within an
8
+ # instance as well as class methods for creating and queuing the jobs.
9
+ #
10
+ # All you have to do to get this functionality is include Resque::Plugins::Status
11
+ # and then implement a <tt>perform<tt> method.
12
+ #
13
+ # For example
14
+ #
15
+ # class ExampleJob
16
+ # include Resque::Plugins::Status
17
+ #
18
+ # def perform
19
+ # num = options['num']
20
+ # i = 0
21
+ # while i < num
22
+ # i += 1
23
+ # at(i, num)
24
+ # end
25
+ # completed("Finished!")
26
+ # end
27
+ #
28
+ # end
29
+ #
30
+ # This job would iterate num times updating the status as it goes. At the end
31
+ # we update the status telling anyone listening to this job that its complete.
32
+ module Status
33
+
34
+ # The error class raised when a job is killed
35
+ class Killed < RuntimeError; end
36
+
37
+ attr_reader :uuid, :options
38
+
39
+ def self.included(base)
40
+ base.extend(ClassMethods)
41
+ end
42
+
43
+ module ClassMethods
44
+
45
+ # The default queue is :statused, this can be ovveridden in the specific job
46
+ # class to put the jobs on a specific worker queue
47
+ def queue
48
+ :statused
49
+ end
50
+
51
+ # used when displaying the Job in the resque-web UI and identifiyng the job
52
+ # type by status. By default this is the name of the job class, but can be
53
+ # ovveridden in the specific job class to present a more user friendly job
54
+ # name
55
+ def name
56
+ self.to_s
57
+ end
58
+
59
+ # Create is the primary method for adding jobs to the queue. This would be
60
+ # called on the job class to create a job of that type. Any options passed are
61
+ # passed to the Job instance as a hash of options. It returns the UUID of the
62
+ # job.
63
+ #
64
+ # == Example:
65
+ #
66
+ # class ExampleJob
67
+ # include Resque::Plugins::Status
68
+ #
69
+ # def perform
70
+ # set_status "Hey I'm a job num #{options['num']}"
71
+ # end
72
+ #
73
+ # end
74
+ #
75
+ # job_id = ExampleJob.create(:num => 100)
76
+ #
77
+ def create(options = {})
78
+ self.enqueue(self, options)
79
+ end
80
+
81
+ # Adds a job of type <tt>klass<tt> to the queue with <tt>options<tt>.
82
+ # Returns the UUID of the job
83
+ def enqueue(klass, options = {})
84
+ uuid = Resque::Plugins::Status::Hash.create :options => options
85
+ Resque.enqueue(klass, uuid, options)
86
+ uuid
87
+ end
88
+
89
+ # This is the method called by Resque::Worker when processing jobs. It
90
+ # creates a new instance of the job class and populates it with the uuid and
91
+ # options.
92
+ #
93
+ # You should not override this method, rahter the <tt>perform</tt> instance method.
94
+ def perform(uuid=nil, options = {})
95
+ uuid ||= Resque::Plugins::Status::Hash.generate_uuid
96
+ instance = new(uuid, options)
97
+ instance.safe_perform!
98
+ instance
99
+ end
100
+
101
+ # Wrapper API to forward a Resque::Job creation API call into a Resque::Plugins::Status call.
102
+ # This is needed to be used with resque scheduler
103
+ # http://github.com/bvandenbos/resque-scheduler
104
+ def scheduled(queue, klass, *args)
105
+ create(*args)
106
+ end
107
+ end
108
+
109
+ # Create a new instance with <tt>uuid</tt> and <tt>options</tt>
110
+ def initialize(uuid, options = {})
111
+ @uuid = uuid
112
+ @options = options
113
+ end
114
+
115
+ # Run by the Resque::Worker when processing this job. It wraps the <tt>perform</tt>
116
+ # method ensuring that the final status of the job is set regardless of error.
117
+ # If an error occurs within the job's work, it will set the status as failed and
118
+ # re-raise the error.
119
+ def safe_perform!
120
+ set_status({'status' => 'working'})
121
+ perform
122
+ completed unless status && status.completed?
123
+ on_success if respond_to?(:on_success)
124
+ rescue Killed
125
+ logger.info "Job #{self} Killed at #{Time.now}"
126
+ Resque::Plugins::Status::Hash.killed(uuid)
127
+ on_killed if respond_to?(:on_killed)
128
+ rescue => e
129
+ logger.error e
130
+ failed("The task failed because of an error: #{e}")
131
+ if respond_to?(:on_failure)
132
+ on_failure(e)
133
+ else
134
+ raise e
135
+ end
136
+ end
137
+
138
+ # Returns a Redisk::Logger object scoped to this paticular job/uuid
139
+ def logger
140
+ @logger ||= Resque::Plugins::Status::Hash.logger(uuid)
141
+ end
142
+
143
+ # Set the jobs status. Can take an array of strings or hashes that are merged
144
+ # (in order) into a final status hash.
145
+ def status=(new_status)
146
+ Resque::Plugins::Status::Hash.set(uuid, *new_status)
147
+ end
148
+
149
+ # get the Resque::Plugins::Status::Hash object for the current uuid
150
+ def status
151
+ Resque::Plugins::Status::Hash.get(uuid)
152
+ end
153
+
154
+ def name
155
+ "#{self.class.name}(#{options.inspect unless options.empty?})"
156
+ end
157
+
158
+ # Checks against the kill list if this specific job instance should be killed
159
+ # on the next iteration
160
+ def should_kill?
161
+ Resque::Plugins::Status::Hash.should_kill?(uuid)
162
+ end
163
+
164
+ # set the status of the job for the current itteration. <tt>num</tt> and
165
+ # <tt>total</tt> are passed to the status as well as any messages.
166
+ # This will kill the job if it has been added to the kill list with
167
+ # <tt>Resque::Plugins::Status::Hash.kill()</tt>
168
+ def at(num, total, *messages)
169
+ tick({
170
+ 'num' => num,
171
+ 'total' => total
172
+ }, *messages)
173
+ end
174
+
175
+ # sets the status of the job for the current itteration. You should use
176
+ # the <tt>at</tt> method if you have actual numbers to track the iteration count.
177
+ # This will kill the job if it has been added to the kill list with
178
+ # <tt>Resque::Plugins::Status::Hash.kill()</tt>
179
+ def tick(*messages)
180
+ kill! if should_kill?
181
+ set_status({'status' => 'working'}, *messages)
182
+ end
183
+
184
+ # set the status to 'failed' passing along any additional messages
185
+ def failed(*messages)
186
+ set_status({'status' => 'failed'}, *messages)
187
+ end
188
+
189
+ # set the status to 'completed' passing along any addional messages
190
+ def completed(*messages)
191
+ set_status({
192
+ 'status' => 'completed',
193
+ 'message' => "Completed at #{Time.now}"
194
+ }, *messages)
195
+ end
196
+
197
+ # kill the current job, setting the status to 'killed' and raising <tt>Killed</tt>
198
+ def kill!
199
+ set_status({
200
+ 'status' => 'killed',
201
+ 'message' => "Killed at #{Time.now}"
202
+ })
203
+ raise Killed
204
+ end
205
+
206
+ private
207
+ def set_status(*args)
208
+ self.status = [status, {'name' => self.name}, args].flatten
209
+ end
210
+
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,242 @@
1
+ module Resque
2
+ module Plugins
3
+ module Status
4
+ VERSION = '0.3.0'
5
+
6
+ # Resque::Plugins::Status::Hash is a Hash object that has helper methods for dealing with
7
+ # the common status attributes. It also has a number of class methods for
8
+ # creating/updating/retrieving status objects from Redis
9
+ class Hash < ::Hash
10
+
11
+ extend Resque::Helpers
12
+
13
+ # Create a status, generating a new UUID, passing the message to the status
14
+ # Returns the UUID of the new status.
15
+ def self.create(*messages)
16
+ uuid = generate_uuid
17
+ set(uuid, *messages)
18
+ redis.zadd(set_key, Time.now.to_i, uuid)
19
+ redis.zremrangebyscore(set_key, 0, Time.now.to_i - @expire_in) if @expire_in
20
+ uuid
21
+ end
22
+
23
+ # Get a status by UUID. Returns a Resque::Plugins::Status::Hash
24
+ def self.get(uuid)
25
+ val = redis.get(status_key(uuid))
26
+ val ? Resque::Plugins::Status::Hash.new(uuid, decode(val)) : nil
27
+ end
28
+
29
+ # set a status by UUID. <tt>messages</tt> can be any number of stirngs or hashes
30
+ # that are merged in order to create a single status.
31
+ def self.set(uuid, *messages)
32
+ val = Resque::Plugins::Status::Hash.new(uuid, *messages)
33
+ redis.set(status_key(uuid), encode(val))
34
+ if expire_in
35
+ redis.expire(status_key(uuid), expire_in)
36
+ end
37
+ val
38
+ end
39
+
40
+ # clear statuses from redis passing an optional range. See `statuses` for info
41
+ # about ranges
42
+ def self.clear(range_start = nil, range_end = nil)
43
+ status_ids(range_start, range_end).each do |id|
44
+ redis.del(status_key(id))
45
+ redis.zrem(set_key, id)
46
+ end
47
+ end
48
+
49
+ # returns a Redisk::Logger scoped to the UUID. Any options passed are passed
50
+ # to the logger initialization.
51
+ #
52
+ # Ensures that Redisk is logging to the same Redis connection as Resque.
53
+ def self.logger(uuid, options = {})
54
+ Redisk.redis = redis
55
+ Redisk::Logger.new(logger_key(uuid), options)
56
+ end
57
+
58
+ def self.count
59
+ redis.zcard(set_key)
60
+ end
61
+
62
+ # Return <tt>num</tt> Resque::Plugins::Status::Hash objects in reverse chronological order.
63
+ # By default returns the entire set.
64
+ # @param [Numeric] range_start The optional starting range
65
+ # @param [Numeric] range_end The optional ending range
66
+ # @example retuning the last 20 statuses
67
+ # Resque::Plugins::Status::Hash.statuses(0, 20)
68
+ def self.statuses(range_start = nil, range_end = nil)
69
+ status_ids(range_start, range_end).collect do |id|
70
+ get(id)
71
+ end.compact
72
+ end
73
+
74
+ # Return the <tt>num</tt> most recent status/job UUIDs in reverse chronological order.
75
+ def self.status_ids(range_start = nil, range_end = nil)
76
+ unless range_end && range_start
77
+ # Because we want a reverse chronological order, we need to get a range starting
78
+ # by the higest negative number.
79
+ redis.zrevrange(set_key, 0, -1) || []
80
+ else
81
+ # Because we want a reverse chronological order, we need to get a range starting
82
+ # by the higest negative number. The ordering is transparent from the API user's
83
+ # perspective so we need to convert the passed params
84
+ (redis.zrevrange(set_key, (range_start.abs), ((range_end || 1).abs)) || [])
85
+ end
86
+ end
87
+
88
+ # Kill the job at UUID on its next iteration this works by adding the UUID to a
89
+ # kill list (a.k.a. a list of jobs to be killed. Each iteration the job checks
90
+ # if it _should_ be killed by calling <tt>tick</tt> or <tt>at</tt>. If so, it raises
91
+ # a <tt>Resque::Plugins::Status::Killed</tt> error and sets the status to 'killed'.
92
+ def self.kill(uuid)
93
+ redis.sadd(kill_key, uuid)
94
+ end
95
+
96
+ # Remove the job at UUID from the kill list
97
+ def self.killed(uuid)
98
+ redis.srem(kill_key, uuid)
99
+ end
100
+
101
+ # Return the UUIDs of the jobs on the kill list
102
+ def self.kill_ids
103
+ redis.smembers(kill_key)
104
+ end
105
+
106
+ # Check whether a job with UUID is on the kill list
107
+ def self.should_kill?(uuid)
108
+ redis.sismember(kill_key, uuid)
109
+ end
110
+
111
+ # The time in seconds that jobs and statuses should expire from Redis (after
112
+ # the last time they are touched/updated)
113
+ def self.expire_in
114
+ @expire_in
115
+ end
116
+
117
+ # Set the <tt>expire_in</tt> time in seconds
118
+ def self.expire_in=(seconds)
119
+ @expire_in = seconds.nil? ? nil : seconds.to_i
120
+ end
121
+
122
+ def self.status_key(uuid)
123
+ "status:#{uuid}"
124
+ end
125
+
126
+ def self.set_key
127
+ "_statuses"
128
+ end
129
+
130
+ def self.kill_key
131
+ "_kill"
132
+ end
133
+
134
+ def self.logger_key(uuid)
135
+ "_log:#{uuid}"
136
+ end
137
+
138
+ def self.generate_uuid
139
+ UUID.generate(:compact)
140
+ end
141
+
142
+ def self.hash_accessor(name, options = {})
143
+ options[:default] ||= nil
144
+ coerce = options[:coerce] ? ".#{options[:coerce]}" : ""
145
+ module_eval <<-EOT
146
+ def #{name}
147
+ value = (self['#{name}'] ? self['#{name}']#{coerce} : #{options[:default].inspect})
148
+ yield value if block_given?
149
+ value
150
+ end
151
+
152
+ def #{name}=(value)
153
+ self['#{name}'] = value
154
+ end
155
+
156
+ def #{name}?
157
+ !!self['#{name}']
158
+ end
159
+ EOT
160
+ end
161
+
162
+ STATUSES = %w{queued working completed failed killed}.freeze
163
+
164
+ hash_accessor :uuid
165
+ hash_accessor :name
166
+ hash_accessor :status
167
+ hash_accessor :message
168
+ hash_accessor :time
169
+ hash_accessor :options
170
+
171
+ hash_accessor :num
172
+ hash_accessor :total
173
+
174
+ # Create a new Resque::Plugins::Status::Hash object. If multiple arguments are passed
175
+ # it is assumed the first argument is the UUID and the rest are status objects.
176
+ # All arguments are subsequentily merged in order. Strings are assumed to
177
+ # be messages.
178
+ def initialize(*args)
179
+ super nil
180
+ base_status = {
181
+ 'time' => Time.now.to_i,
182
+ 'status' => 'queued'
183
+ }
184
+ base_status['uuid'] = args.shift if args.length > 1
185
+ status_hash = args.inject(base_status) do |final, m|
186
+ m = {'message' => m} if m.is_a?(String)
187
+ final.merge(m || {})
188
+ end
189
+ self.replace(status_hash)
190
+ end
191
+
192
+ # calculate the % completion of the job based on <tt>status</tt>, <tt>num</tt>
193
+ # and <tt>total</tt>
194
+ def pct_complete
195
+ case status
196
+ when 'completed' then 100
197
+ when 'queued' then 0
198
+ else
199
+ t = (total == 0 || total.nil?) ? 1 : total
200
+ (((num || 0).to_f / t.to_f) * 100).to_i
201
+ end
202
+ end
203
+
204
+ # Return the time of the status initialization. If set returns a <tt>Time</tt>
205
+ # object, otherwise returns nil
206
+ def time
207
+ time? ? Time.at(self['time']) : nil
208
+ end
209
+
210
+ STATUSES.each do |status|
211
+ define_method("#{status}?") do
212
+ self['status'] === status
213
+ end
214
+ end
215
+
216
+ # Can the job be killed? 'failed', 'completed', and 'killed' jobs cant be killed
217
+ # (for pretty obvious reasons)
218
+ def killable?
219
+ !['failed', 'completed', 'killed'].include?(self.status)
220
+ end
221
+
222
+ unless method_defined?(:to_json)
223
+ def to_json(*args)
224
+ json
225
+ end
226
+ end
227
+
228
+ # Return a JSON representation of the current object.
229
+ def json
230
+ h = self.dup
231
+ h['pct_complete'] = pct_complete
232
+ self.class.encode(h)
233
+ end
234
+
235
+ def inspect
236
+ "#<Resque::Plugins::Status::Hash #{super}>"
237
+ end
238
+
239
+ end
240
+ end
241
+ end
242
+ end