dburkes-delayed_job 1.7.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.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2005 Tobias Luetke
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 PURPOa AND
17
+ NONINFRINGEMENT. IN NO EVENT SaALL 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.textile ADDED
@@ -0,0 +1,111 @@
1
+ h1. Delayed::Job
2
+
3
+ Delated_job (or DJ) encapsulates the common pattern of asynchronously executing longer tasks in the background.
4
+
5
+ It is a direct extraction from Shopify where the job table is responsible for a multitude of core tasks. Amongst those tasks are:
6
+
7
+ * sending massive newsletters
8
+ * image resizing
9
+ * http downloads
10
+ * updating smart collections
11
+ * updating solr, our search server, after product changes
12
+ * batch imports
13
+ * spam checks
14
+
15
+ h2. Setup
16
+
17
+ The library evolves around a delayed_jobs table which looks as follows:
18
+
19
+ create_table :delayed_jobs, :force => true do |table|
20
+ table.integer :priority, :default => 0 # Allows some jobs to jump to the front of the queue
21
+ table.integer :attempts, :default => 0 # Provides for retries, but still fail eventually.
22
+ table.text :handler # YAML-encoded string of the object that will do work
23
+ table.string :last_error # reason for last failure (See Note below)
24
+ table.datetime :run_at # When to run. Could be Time.now for immediately, or sometime in the future.
25
+ table.datetime :locked_at # Set when a client is working on this object
26
+ table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead)
27
+ table.string :locked_by # Who is working on this object (if locked)
28
+ table.timestamps
29
+ end
30
+
31
+ On failure, the job is scheduled again in 5 seconds + N ** 4, where N is the number of retries.
32
+
33
+ The default MAX_ATTEMPTS is 25- jobs can override this value by responding to :max_attempts. After this, the job
34
+ either deleted (default), or left in the database with "failed_at" set. With the default of 25 attempts, the last
35
+ retry will be 20 days later, with the last interval being almost 100 hours.
36
+
37
+ The default MAX_RUN_TIME is 4.hours. If your job takes longer than that, another computer could pick it up. It's up to you to
38
+ make sure your job doesn't exceed this time. You should set this to the longest time you think the job could take.
39
+
40
+ By default, it will delete failed jobs (and it always deletes successful jobs). If you want to keep failed jobs, set
41
+ Delayed::Job.destroy_failed_jobs = false. The failed jobs will be marked with non-null failed_at.
42
+
43
+ Here is an example of changing job parameters in Rails:
44
+
45
+ # config/initializers/delayed_job_config.rb
46
+ Delayed::Job.destroy_failed_jobs = false
47
+ silence_warnings do
48
+ Delayed::Job.const_set("MAX_ATTEMPTS", 3)
49
+ Delayed::Job.const_set("MAX_RUN_TIME", 5.minutes)
50
+ end
51
+
52
+ Note: If your error messages are long, consider changing last_error field to a :text instead of a :string (255 character limit).
53
+
54
+
55
+ h2. Usage
56
+
57
+ Jobs are simple ruby objects with a method called perform. Any object which responds to perform can be stuffed into the jobs table.
58
+ Job objects are serialized to yaml so that they can later be resurrected by the job runner.
59
+
60
+ class NewsletterJob < Struct.new(:text, :emails)
61
+ def perform
62
+ emails.each { |e| NewsletterMailer.deliver_text_to_email(text, e) }
63
+ end
64
+ end
65
+
66
+ Delayed::Job.enqueue NewsletterJob.new('lorem ipsum...', Customers.find(:all).collect(&:email))
67
+
68
+ There is also a second way to get jobs in the queue: send_later.
69
+
70
+
71
+ BatchImporter.new(Shop.find(1)).send_later(:import_massive_csv, massive_csv)
72
+
73
+
74
+ This will simply create a Delayed::PerformableMethod job in the jobs table which serializes all the parameters you pass to it. There are some special smarts for active record objects
75
+ which are stored as their text representation and loaded from the database fresh when the job is actually run later.
76
+
77
+
78
+ h2. Running the jobs
79
+
80
+ You can invoke @rake jobs:work@ which will start working off jobs. You can cancel the rake task with @CTRL-C@.
81
+
82
+ You can also run by writing a simple @script/job_runner@, and invoking it externally:
83
+
84
+ <pre><code>
85
+ #!/usr/bin/env ruby
86
+ require File.dirname(__FILE__) + '/../config/environment'
87
+
88
+ Delayed::Worker.new.start
89
+ </code></pre>
90
+
91
+ Workers can be running on any computer, as long as they have access to the database and their clock is in sync. You can even
92
+ run multiple workers on per computer, but you must give each one a unique name. (TODO: put in an example)
93
+ Keep in mind that each worker will check the database at least every 5 seconds.
94
+
95
+ Note: The rake task will exit if the database has any network connectivity problems.
96
+
97
+ h3. Cleaning up
98
+
99
+ You can invoke @rake jobs:clear@ to delete all jobs in the queue.
100
+
101
+ h3. Changes
102
+
103
+ * 1.7.0: Added failed_at column which can optionally be set after a certain amount of failed job attempts. By default failed job attempts are destroyed after about a month.
104
+
105
+ * 1.6.0: Renamed locked_until to locked_at. We now store when we start a given job instead of how long it will be locked by the worker. This allows us to get a reading on how long a job took to execute.
106
+
107
+ * 1.5.0: Job runners can now be run in parallel. Two new database columns are needed: locked_until and locked_by. This allows us to use pessimistic locking instead of relying on row level locks. This enables us to run as many worker processes as we need to speed up queue processing.
108
+
109
+ * 1.2.0: Added #send_later to Object for simpler job creation
110
+
111
+ * 1.0.0: Initial release
@@ -0,0 +1,41 @@
1
+ #version = File.read('README.textile').scan(/^\*\s+([\d\.]+)/).flatten
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "delayed_job"
5
+ s.version = "1.7.0"
6
+ s.date = "2008-11-28"
7
+ s.summary = "Database-backed asynchronous priority queue system -- Extracted from Shopify"
8
+ s.email = "tobi@leetsoft.com"
9
+ s.homepage = "http://github.com/tobi/delayed_job/tree/master"
10
+ s.description = "Delated_job (or DJ) encapsulates the common pattern of asynchronously executing longer tasks in the background. It is a direct extraction from Shopify where the job table is responsible for a multitude of core tasks."
11
+ s.authors = ["Tobias Lütke"]
12
+
13
+ # s.bindir = "bin"
14
+ # s.executables = ["delayed_job"]
15
+ # s.default_executable = "delayed_job"
16
+
17
+ s.has_rdoc = false
18
+ s.rdoc_options = ["--main", "README.textile"]
19
+ s.extra_rdoc_files = ["README.textile"]
20
+
21
+ # run git ls-files to get an updated list
22
+ s.files = %w[
23
+ MIT-LICENSE
24
+ README.textile
25
+ delayed_job.gemspec
26
+ init.rb
27
+ lib/delayed/job.rb
28
+ lib/delayed/message_sending.rb
29
+ lib/delayed/performable_method.rb
30
+ lib/delayed/worker.rb
31
+ lib/delayed_job.rb
32
+ tasks/jobs.rake
33
+ tasks/tasks.rb
34
+ ]
35
+ s.test_files = %w[
36
+ spec/database.rb
37
+ spec/delayed_method_spec.rb
38
+ spec/job_spec.rb
39
+ spec/story_spec.rb
40
+ ]
41
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require File.dirname(__FILE__) + '/lib/delayed_job'
@@ -0,0 +1,272 @@
1
+ module Delayed
2
+
3
+ class DeserializationError < StandardError
4
+ end
5
+
6
+ # A job object that is persisted to the database.
7
+ # Contains the work object as a YAML field.
8
+ class Job < ActiveRecord::Base
9
+ MAX_ATTEMPTS = 25
10
+ MAX_RUN_TIME = 4.hours
11
+ set_table_name :delayed_jobs
12
+
13
+ # By default failed jobs are destroyed after too many attempts.
14
+ # If you want to keep them around (perhaps to inspect the reason
15
+ # for the failure), set this to false.
16
+ cattr_accessor :destroy_failed_jobs
17
+ self.destroy_failed_jobs = true
18
+
19
+ # Every worker has a unique name which by default is the pid of the process.
20
+ # There are some advantages to overriding this with something which survives worker retarts:
21
+ # Workers can safely resume working on tasks which are locked by themselves. The worker will assume that it crashed before.
22
+ cattr_accessor :worker_name
23
+ self.worker_name = "host:#{Socket.gethostname} pid:#{Process.pid}" rescue "pid:#{Process.pid}"
24
+
25
+ NextTaskSQL = '(run_at <= ? AND (locked_at IS NULL OR locked_at < ?) OR (locked_by = ?)) AND failed_at IS NULL'
26
+ NextTaskOrder = 'priority DESC, run_at ASC'
27
+
28
+ ParseObjectFromYaml = /\!ruby\/\w+\:([^\s]+)/
29
+
30
+ cattr_accessor :min_priority, :max_priority
31
+ self.min_priority = nil
32
+ self.max_priority = nil
33
+
34
+ # When a worker is exiting, make sure we don't have any locked jobs.
35
+ def self.clear_locks!
36
+ update_all("locked_by = null, locked_at = null", ["locked_by = ?", worker_name])
37
+ end
38
+
39
+ def failed?
40
+ failed_at
41
+ end
42
+ alias_method :failed, :failed?
43
+
44
+ def payload_object
45
+ @payload_object ||= deserialize(self['handler'])
46
+ end
47
+
48
+ def name
49
+ @name ||= begin
50
+ payload = payload_object
51
+ if payload.respond_to?(:display_name)
52
+ payload.display_name
53
+ else
54
+ payload.class.name
55
+ end
56
+ end
57
+ end
58
+
59
+ def payload_object=(object)
60
+ self['handler'] = object.to_yaml
61
+ end
62
+
63
+ # Reschedule the job in the future (when a job fails).
64
+ # Uses an exponential scale depending on the number of failed attempts.
65
+ def reschedule(message, backtrace = [], time = nil)
66
+ if self.attempts < (payload_object.send(:max_attempts) rescue MAX_ATTEMPTS)
67
+ time ||= Job.db_time_now + (attempts ** 4) + 5
68
+
69
+ self.attempts += 1
70
+ self.run_at = time
71
+ self.last_error = message + "\n" + backtrace.join("\n")
72
+ self.unlock
73
+ save!
74
+ else
75
+ logger.info "* [JOB] PERMANENTLY removing #{self.name} because of #{attempts} consequetive failures."
76
+ destroy_failed_jobs ? destroy : update_attribute(:failed_at, Time.now)
77
+ end
78
+ end
79
+
80
+
81
+ # Try to run one job. Returns true/false (work done/work failed) or nil if job can't be locked.
82
+ def run_with_lock(max_run_time, worker_name)
83
+ logger.info "* [JOB] aquiring lock on #{name}"
84
+ unless lock_exclusively!(max_run_time, worker_name)
85
+ # We did not get the lock, some other worker process must have
86
+ logger.warn "* [JOB] failed to aquire exclusive lock for #{name}"
87
+ return nil # no work done
88
+ end
89
+
90
+ begin
91
+ runtime = Benchmark.realtime do
92
+ invoke_job # TODO: raise error if takes longer than max_run_time
93
+ destroy
94
+ end
95
+ # TODO: warn if runtime > max_run_time ?
96
+ logger.info "* [JOB] #{name} completed after %.4f" % runtime
97
+ return true # did work
98
+ rescue Exception => e
99
+ reschedule e.message, e.backtrace
100
+ log_exception(e)
101
+ return false # work failed
102
+ end
103
+ end
104
+
105
+ # Add a job to the queue
106
+ def self.enqueue(*args, &block)
107
+ object = block_given? ? EvaledJob.new(&block) : args.shift
108
+
109
+ unless object.respond_to?(:perform) || block_given?
110
+ raise ArgumentError, 'Cannot enqueue items which do not respond to perform'
111
+ end
112
+
113
+ priority = args.first || 0
114
+ run_at = args[1]
115
+
116
+ Job.create(:payload_object => object, :priority => priority.to_i, :run_at => run_at)
117
+ end
118
+
119
+ # Find a few candidate jobs to run (in case some immediately get locked by others).
120
+ # Return in random order prevent everyone trying to do same head job at once.
121
+ def self.find_available(limit = 5, max_run_time = MAX_RUN_TIME)
122
+
123
+ time_now = db_time_now
124
+
125
+ sql = NextTaskSQL.dup
126
+
127
+ conditions = [time_now, time_now - max_run_time, worker_name]
128
+
129
+ if self.min_priority
130
+ sql << ' AND (priority >= ?)'
131
+ conditions << min_priority
132
+ end
133
+
134
+ if self.max_priority
135
+ sql << ' AND (priority <= ?)'
136
+ conditions << max_priority
137
+ end
138
+
139
+ conditions.unshift(sql)
140
+
141
+ records = ActiveRecord::Base.silence do
142
+ find(:all, :conditions => conditions, :order => NextTaskOrder, :limit => limit)
143
+ end
144
+
145
+ records.sort_by { rand() }
146
+ end
147
+
148
+ # Run the next job we can get an exclusive lock on.
149
+ # If no jobs are left we return nil
150
+ def self.reserve_and_run_one_job(max_run_time = MAX_RUN_TIME)
151
+
152
+ # We get up to 5 jobs from the db. In case we cannot get exclusive access to a job we try the next.
153
+ # this leads to a more even distribution of jobs across the worker processes
154
+ find_available(5, max_run_time).each do |job|
155
+ t = job.run_with_lock(max_run_time, worker_name)
156
+ return t unless t == nil # return if we did work (good or bad)
157
+ end
158
+
159
+ nil # we didn't do any work, all 5 were not lockable
160
+ end
161
+
162
+ # Lock this job for this worker.
163
+ # Returns true if we have the lock, false otherwise.
164
+ def lock_exclusively!(max_run_time, worker = worker_name)
165
+ now = self.class.db_time_now
166
+ affected_rows = if locked_by != worker
167
+ # We don't own this job so we will update the locked_by name and the locked_at
168
+ self.class.update_all(["locked_at = ?, locked_by = ?", now, worker], ["id = ? and (locked_at is null or locked_at < ?)", id, (now - max_run_time.to_i)])
169
+ else
170
+ # We already own this job, this may happen if the job queue crashes.
171
+ # Simply resume and update the locked_at
172
+ self.class.update_all(["locked_at = ?", now], ["id = ? and locked_by = ?", id, worker])
173
+ end
174
+ if affected_rows == 1
175
+ self.locked_at = now
176
+ self.locked_by = worker
177
+ return true
178
+ else
179
+ return false
180
+ end
181
+ end
182
+
183
+ # Unlock this job (note: not saved to DB)
184
+ def unlock
185
+ self.locked_at = nil
186
+ self.locked_by = nil
187
+ end
188
+
189
+ # This is a good hook if you need to report job processing errors in additional or different ways
190
+ def log_exception(error)
191
+ logger.error "* [JOB] #{name} failed with #{error.class.name}: #{error.message} - #{attempts} failed attempts"
192
+ logger.error(error)
193
+ end
194
+
195
+ # Do num jobs and return stats on success/failure.
196
+ # Exit early if interrupted.
197
+ def self.work_off(num = 100)
198
+ success, failure = 0, 0
199
+
200
+ num.times do
201
+ case self.reserve_and_run_one_job
202
+ when true
203
+ success += 1
204
+ when false
205
+ failure += 1
206
+ else
207
+ break # leave if no work could be done
208
+ end
209
+ break if $exit # leave if we're exiting
210
+ end
211
+
212
+ return [success, failure]
213
+ end
214
+
215
+ # Moved into its own method so that new_relic can trace it.
216
+ def invoke_job
217
+ payload_object.perform
218
+ end
219
+
220
+ private
221
+
222
+ def deserialize(source)
223
+ handler = YAML.load(source) rescue nil
224
+
225
+ unless handler.respond_to?(:perform)
226
+ if handler.nil? && source =~ ParseObjectFromYaml
227
+ handler_class = $1
228
+ end
229
+ attempt_to_load(handler_class || handler.class)
230
+ handler = YAML.load(source)
231
+ end
232
+
233
+ return handler if handler.respond_to?(:perform)
234
+
235
+ raise DeserializationError,
236
+ 'Job failed to load: Unknown handler. Try to manually require the appropiate file.'
237
+ rescue TypeError, LoadError, NameError => e
238
+ raise DeserializationError,
239
+ "Job failed to load: #{e.message}. Try to manually require the required file."
240
+ end
241
+
242
+ # Constantize the object so that ActiveSupport can attempt
243
+ # its auto loading magic. Will raise LoadError if not successful.
244
+ def attempt_to_load(klass)
245
+ klass.constantize
246
+ end
247
+
248
+ # Get the current time (GMT or local depending on DB)
249
+ # Note: This does not ping the DB to get the time, so all your clients
250
+ # must have syncronized clocks.
251
+ def self.db_time_now
252
+ (ActiveRecord::Base.default_timezone == :utc) ? Time.now.utc : Time.now
253
+ end
254
+
255
+ protected
256
+
257
+ def before_save
258
+ self.run_at ||= self.class.db_time_now
259
+ end
260
+
261
+ end
262
+
263
+ class EvaledJob
264
+ def initialize
265
+ @job = yield
266
+ end
267
+
268
+ def perform
269
+ eval(@job)
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,17 @@
1
+ module Delayed
2
+ module MessageSending
3
+ def send_later(method, *args)
4
+ Delayed::Job.enqueue Delayed::PerformableMethod.new(self, method.to_sym, args)
5
+ end
6
+
7
+ module ClassMethods
8
+ def handle_asynchronously(method)
9
+ without_name = "#{method}_without_send_later"
10
+ define_method("#{method}_with_send_later") do |*args|
11
+ send_later(without_name, *args)
12
+ end
13
+ alias_method_chain method, :send_later
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,55 @@
1
+ module Delayed
2
+ class PerformableMethod < Struct.new(:object, :method, :args)
3
+ CLASS_STRING_FORMAT = /^CLASS\:([A-Z][\w\:]+)$/
4
+ AR_STRING_FORMAT = /^AR\:([A-Z][\w\:]+)\:(\d+)$/
5
+
6
+ def initialize(object, method, args)
7
+ raise NoMethodError, "undefined method `#{method}' for #{self.inspect}" unless object.respond_to?(method)
8
+
9
+ self.object = dump(object)
10
+ self.args = args.map { |a| dump(a) }
11
+ self.method = method.to_sym
12
+ end
13
+
14
+ def display_name
15
+ case self.object
16
+ when CLASS_STRING_FORMAT then "#{$1}.#{method}"
17
+ when AR_STRING_FORMAT then "#{$1}##{method}"
18
+ else "Unknown##{method}"
19
+ end
20
+ end
21
+
22
+ def perform
23
+ load(object).send(method, *args.map{|a| load(a)})
24
+ rescue ActiveRecord::RecordNotFound
25
+ # We cannot do anything about objects which were deleted in the meantime
26
+ true
27
+ end
28
+
29
+ private
30
+
31
+ def load(arg)
32
+ case arg
33
+ when CLASS_STRING_FORMAT then $1.constantize
34
+ when AR_STRING_FORMAT then $1.constantize.find($2)
35
+ else arg
36
+ end
37
+ end
38
+
39
+ def dump(arg)
40
+ case arg
41
+ when Class then class_to_string(arg)
42
+ when ActiveRecord::Base then ar_to_string(arg)
43
+ else arg
44
+ end
45
+ end
46
+
47
+ def ar_to_string(obj)
48
+ "AR:#{obj.class}:#{obj.id}"
49
+ end
50
+
51
+ def class_to_string(obj)
52
+ "CLASS:#{obj.name}"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ module Delayed
2
+ class Worker
3
+ SLEEP = 5
4
+
5
+ cattr_accessor :logger
6
+ self.logger = if defined?(Merb::Logger)
7
+ Merb.logger
8
+ elsif defined?(RAILS_DEFAULT_LOGGER)
9
+ RAILS_DEFAULT_LOGGER
10
+ end
11
+
12
+ def initialize(options={})
13
+ @quiet = options[:quiet]
14
+ Delayed::Job.min_priority = options[:min_priority] if options.has_key?(:min_priority)
15
+ Delayed::Job.max_priority = options[:max_priority] if options.has_key?(:max_priority)
16
+ end
17
+
18
+ def start
19
+ say "*** Starting job worker #{Delayed::Job.worker_name}"
20
+
21
+ trap('TERM') { say 'Exiting...'; $exit = true }
22
+ trap('INT') { say 'Exiting...'; $exit = true }
23
+
24
+ loop do
25
+ result = nil
26
+
27
+ realtime = Benchmark.realtime do
28
+ result = Delayed::Job.work_off
29
+ end
30
+
31
+ count = result.sum
32
+
33
+ break if $exit
34
+
35
+ if count.zero?
36
+ sleep(SLEEP)
37
+ else
38
+ say "#{count} jobs processed at %.4f j/s, %d failed ..." % [count / realtime, result.last]
39
+ end
40
+
41
+ break if $exit
42
+ end
43
+
44
+ ensure
45
+ Delayed::Job.clear_locks!
46
+ end
47
+
48
+ def say(text)
49
+ puts text unless @quiet
50
+ logger.info text if logger
51
+ end
52
+
53
+ end
54
+ end
@@ -0,0 +1,13 @@
1
+ autoload :ActiveRecord, 'activerecord'
2
+
3
+ require File.dirname(__FILE__) + '/delayed/message_sending'
4
+ require File.dirname(__FILE__) + '/delayed/performable_method'
5
+ require File.dirname(__FILE__) + '/delayed/job'
6
+ require File.dirname(__FILE__) + '/delayed/worker'
7
+
8
+ Object.send(:include, Delayed::MessageSending)
9
+ Module.send(:include, Delayed::MessageSending::ClassMethods)
10
+
11
+ if defined?(Merb::Plugins)
12
+ Merb::Plugins.add_rakefiles File.dirname(__FILE__) / '..' / 'tasks' / 'tasks'
13
+ end
data/spec/database.rb ADDED
@@ -0,0 +1,42 @@
1
+ $:.unshift(File.dirname(__FILE__) + '/../lib')
2
+ $:.unshift(File.dirname(__FILE__) + '/../../rspec/lib')
3
+
4
+ require 'rubygems'
5
+ require 'active_record'
6
+ gem 'sqlite3-ruby'
7
+
8
+ require File.dirname(__FILE__) + '/../init'
9
+ require 'spec'
10
+
11
+ ActiveRecord::Base.logger = Logger.new('/tmp/dj.log')
12
+ ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => '/tmp/jobs.sqlite')
13
+ ActiveRecord::Migration.verbose = false
14
+
15
+ ActiveRecord::Schema.define do
16
+
17
+ create_table :delayed_jobs, :force => true do |table|
18
+ table.integer :priority, :default => 0
19
+ table.integer :attempts, :default => 0
20
+ table.text :handler
21
+ table.string :last_error
22
+ table.datetime :run_at
23
+ table.datetime :locked_at
24
+ table.string :locked_by
25
+ table.datetime :failed_at
26
+ table.timestamps
27
+ end
28
+
29
+ create_table :stories, :force => true do |table|
30
+ table.string :text
31
+ end
32
+
33
+ end
34
+
35
+
36
+ # Purely useful for test cases...
37
+ class Story < ActiveRecord::Base
38
+ def tell; text; end
39
+ def whatever(n, _); tell*n; end
40
+
41
+ handle_asynchronously :whatever
42
+ end
@@ -0,0 +1,128 @@
1
+ require File.dirname(__FILE__) + '/database'
2
+
3
+ class SimpleJob
4
+ cattr_accessor :runs; self.runs = 0
5
+ def perform; @@runs += 1; end
6
+ end
7
+
8
+ class RandomRubyObject
9
+ def say_hello
10
+ 'hello'
11
+ end
12
+ end
13
+
14
+ class ErrorObject
15
+
16
+ def throw
17
+ raise ActiveRecord::RecordNotFound, '...'
18
+ false
19
+ end
20
+
21
+ end
22
+
23
+ class StoryReader
24
+
25
+ def read(story)
26
+ "Epilog: #{story.tell}"
27
+ end
28
+
29
+ end
30
+
31
+ class StoryReader
32
+
33
+ def read(story)
34
+ "Epilog: #{story.tell}"
35
+ end
36
+
37
+ end
38
+
39
+ describe 'random ruby objects' do
40
+ before { Delayed::Job.delete_all }
41
+
42
+ it "should respond_to :send_later method" do
43
+
44
+ RandomRubyObject.new.respond_to?(:send_later)
45
+
46
+ end
47
+
48
+ it "should raise a ArgumentError if send_later is called but the target method doesn't exist" do
49
+ lambda { RandomRubyObject.new.send_later(:method_that_deos_not_exist) }.should raise_error(NoMethodError)
50
+ end
51
+
52
+ it "should add a new entry to the job table when send_later is called on it" do
53
+ Delayed::Job.count.should == 0
54
+
55
+ RandomRubyObject.new.send_later(:to_s)
56
+
57
+ Delayed::Job.count.should == 1
58
+ end
59
+
60
+ it "should add a new entry to the job table when send_later is called on the class" do
61
+ Delayed::Job.count.should == 0
62
+
63
+ RandomRubyObject.send_later(:to_s)
64
+
65
+ Delayed::Job.count.should == 1
66
+ end
67
+
68
+ it "should run get the original method executed when the job is performed" do
69
+
70
+ RandomRubyObject.new.send_later(:say_hello)
71
+
72
+ Delayed::Job.count.should == 1
73
+ end
74
+
75
+ it "should ignore ActiveRecord::RecordNotFound errors because they are permanent" do
76
+
77
+ ErrorObject.new.send_later(:throw)
78
+
79
+ Delayed::Job.count.should == 1
80
+
81
+ Delayed::Job.reserve_and_run_one_job
82
+
83
+ Delayed::Job.count.should == 0
84
+
85
+ end
86
+
87
+ it "should store the object as string if its an active record" do
88
+ story = Story.create :text => 'Once upon...'
89
+ story.send_later(:tell)
90
+
91
+ job = Delayed::Job.find(:first)
92
+ job.payload_object.class.should == Delayed::PerformableMethod
93
+ job.payload_object.object.should == "AR:Story:#{story.id}"
94
+ job.payload_object.method.should == :tell
95
+ job.payload_object.args.should == []
96
+ job.payload_object.perform.should == 'Once upon...'
97
+ end
98
+
99
+ it "should store arguments as string if they an active record" do
100
+
101
+ story = Story.create :text => 'Once upon...'
102
+
103
+ reader = StoryReader.new
104
+ reader.send_later(:read, story)
105
+
106
+ job = Delayed::Job.find(:first)
107
+ job.payload_object.class.should == Delayed::PerformableMethod
108
+ job.payload_object.method.should == :read
109
+ job.payload_object.args.should == ["AR:Story:#{story.id}"]
110
+ job.payload_object.perform.should == 'Epilog: Once upon...'
111
+ end
112
+
113
+ it "should call send later on methods which are wrapped with handle_asynchronously" do
114
+ story = Story.create :text => 'Once upon...'
115
+
116
+ Delayed::Job.count.should == 0
117
+
118
+ story.whatever(1, 5)
119
+
120
+ Delayed::Job.count.should == 1
121
+ job = Delayed::Job.find(:first)
122
+ job.payload_object.class.should == Delayed::PerformableMethod
123
+ job.payload_object.method.should == :whatever_without_send_later
124
+ job.payload_object.args.should == [1, 5]
125
+ job.payload_object.perform.should == 'Once upon...'
126
+ end
127
+
128
+ end
data/spec/job_spec.rb ADDED
@@ -0,0 +1,359 @@
1
+ require File.dirname(__FILE__) + '/database'
2
+
3
+ class SimpleJob
4
+ cattr_accessor :runs; self.runs = 0
5
+ def perform; @@runs += 1; end
6
+ end
7
+
8
+ class ErrorJob
9
+ cattr_accessor :runs; self.runs = 0
10
+ def perform; raise 'did not work'; end
11
+ end
12
+
13
+ module M
14
+ class ModuleJob
15
+ cattr_accessor :runs; self.runs = 0
16
+ def perform; @@runs += 1; end
17
+ end
18
+ end
19
+
20
+ class CustomJob < SimpleJob
21
+ def max_attempts; 3; end
22
+ end
23
+
24
+ describe Delayed::Job do
25
+ before do
26
+ Delayed::Job.max_priority = nil
27
+ Delayed::Job.min_priority = nil
28
+
29
+ Delayed::Job.delete_all
30
+ end
31
+
32
+ before(:each) do
33
+ SimpleJob.runs = 0
34
+ end
35
+
36
+ it "should set run_at automatically if not set" do
37
+ Delayed::Job.create(:payload_object => ErrorJob.new ).run_at.should_not == nil
38
+ end
39
+
40
+ it "should not set run_at automatically if already set" do
41
+ later = 5.minutes.from_now
42
+ Delayed::Job.create(:payload_object => ErrorJob.new, :run_at => later).run_at.should == later
43
+ end
44
+
45
+ it "should raise ArgumentError when handler doesn't respond_to :perform" do
46
+ lambda { Delayed::Job.enqueue(Object.new) }.should raise_error(ArgumentError)
47
+ end
48
+
49
+ it "should increase count after enqueuing items" do
50
+ Delayed::Job.enqueue SimpleJob.new
51
+ Delayed::Job.count.should == 1
52
+ end
53
+
54
+ it "should be able to set priority when enqueuing items" do
55
+ Delayed::Job.enqueue SimpleJob.new, 5
56
+ Delayed::Job.first.priority.should == 5
57
+ end
58
+
59
+ it "should be able to set run_at when enqueuing items" do
60
+ later = 5.minutes.from_now
61
+ Delayed::Job.enqueue SimpleJob.new, 5, later
62
+
63
+ # use be close rather than equal to because millisecond values cn be lost in DB round trip
64
+ Delayed::Job.first.run_at.should be_close(later, 1)
65
+ end
66
+
67
+ it "should call perform on jobs when running work_off" do
68
+ SimpleJob.runs.should == 0
69
+
70
+ Delayed::Job.enqueue SimpleJob.new
71
+ Delayed::Job.work_off
72
+
73
+ SimpleJob.runs.should == 1
74
+ end
75
+
76
+
77
+ it "should work with eval jobs" do
78
+ $eval_job_ran = false
79
+
80
+ Delayed::Job.enqueue do <<-JOB
81
+ $eval_job_ran = true
82
+ JOB
83
+ end
84
+
85
+ Delayed::Job.work_off
86
+
87
+ $eval_job_ran.should == true
88
+ end
89
+
90
+ it "should work with jobs in modules" do
91
+ M::ModuleJob.runs.should == 0
92
+
93
+ Delayed::Job.enqueue M::ModuleJob.new
94
+ Delayed::Job.work_off
95
+
96
+ M::ModuleJob.runs.should == 1
97
+ end
98
+
99
+ it "should re-schedule by about 1 second at first and increment this more and more minutes when it fails to execute properly" do
100
+ Delayed::Job.enqueue ErrorJob.new
101
+ Delayed::Job.work_off(1)
102
+
103
+ job = Delayed::Job.find(:first)
104
+
105
+ job.last_error.should =~ /did not work/
106
+ job.last_error.should =~ /job_spec.rb:10:in `perform'/
107
+ job.attempts.should == 1
108
+
109
+ job.run_at.should > Delayed::Job.db_time_now - 10.minutes
110
+ job.run_at.should < Delayed::Job.db_time_now + 10.minutes
111
+ end
112
+
113
+ it "should raise an DeserializationError when the job class is totally unknown" do
114
+
115
+ job = Delayed::Job.new
116
+ job['handler'] = "--- !ruby/object:JobThatDoesNotExist {}"
117
+
118
+ lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError)
119
+ end
120
+
121
+ it "should try to load the class when it is unknown at the time of the deserialization" do
122
+ job = Delayed::Job.new
123
+ job['handler'] = "--- !ruby/object:JobThatDoesNotExist {}"
124
+
125
+ job.should_receive(:attempt_to_load).with('JobThatDoesNotExist').and_return(true)
126
+
127
+ lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError)
128
+ end
129
+
130
+ it "should try include the namespace when loading unknown objects" do
131
+ job = Delayed::Job.new
132
+ job['handler'] = "--- !ruby/object:Delayed::JobThatDoesNotExist {}"
133
+ job.should_receive(:attempt_to_load).with('Delayed::JobThatDoesNotExist').and_return(true)
134
+ lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError)
135
+ end
136
+
137
+ it "should also try to load structs when they are unknown (raises TypeError)" do
138
+ job = Delayed::Job.new
139
+ job['handler'] = "--- !ruby/struct:JobThatDoesNotExist {}"
140
+
141
+ job.should_receive(:attempt_to_load).with('JobThatDoesNotExist').and_return(true)
142
+
143
+ lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError)
144
+ end
145
+
146
+ it "should try include the namespace when loading unknown structs" do
147
+ job = Delayed::Job.new
148
+ job['handler'] = "--- !ruby/struct:Delayed::JobThatDoesNotExist {}"
149
+
150
+ job.should_receive(:attempt_to_load).with('Delayed::JobThatDoesNotExist').and_return(true)
151
+ lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError)
152
+ end
153
+
154
+ it "should be failed if it failed more than MAX_ATTEMPTS times and we don't want to destroy jobs" do
155
+ default = Delayed::Job.destroy_failed_jobs
156
+ Delayed::Job.destroy_failed_jobs = false
157
+
158
+ @job = Delayed::Job.create :payload_object => SimpleJob.new, :attempts => 50
159
+ @job.reload.failed_at.should == nil
160
+ @job.reschedule 'FAIL'
161
+ @job.reload.failed_at.should_not == nil
162
+
163
+ Delayed::Job.destroy_failed_jobs = default
164
+ end
165
+
166
+ it "should be destroyed if it failed more than MAX_ATTEMPTS times and we want to destroy jobs" do
167
+ default = Delayed::Job.destroy_failed_jobs
168
+ Delayed::Job.destroy_failed_jobs = true
169
+
170
+ @job = Delayed::Job.create :payload_object => SimpleJob.new, :attempts => 50
171
+ @job.should_receive(:destroy)
172
+ @job.reschedule 'FAIL'
173
+
174
+ Delayed::Job.destroy_failed_jobs = default
175
+ end
176
+
177
+ it "should allow jobs to override max_attemps and behave appropriately" do
178
+ default = Delayed::Job.destroy_failed_jobs
179
+ Delayed::Job.destroy_failed_jobs = true
180
+
181
+ @job = Delayed::Job.create :payload_object => CustomJob.new, :attempts => 5
182
+ @job.should_receive(:destroy)
183
+ @job.reschedule 'FAIL'
184
+
185
+ Delayed::Job.destroy_failed_jobs = default
186
+ end
187
+
188
+ it "should never find failed jobs" do
189
+ @job = Delayed::Job.create :payload_object => SimpleJob.new, :attempts => 50, :failed_at => Time.now
190
+ Delayed::Job.find_available(1).length.should == 0
191
+ end
192
+
193
+ context "when another worker is already performing an task, it" do
194
+
195
+ before :each do
196
+ Delayed::Job.worker_name = 'worker1'
197
+ @job = Delayed::Job.create :payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => Delayed::Job.db_time_now - 5.minutes
198
+ end
199
+
200
+ it "should not allow a second worker to get exclusive access" do
201
+ @job.lock_exclusively!(4.hours, 'worker2').should == false
202
+ end
203
+
204
+ it "should allow a second worker to get exclusive access if the timeout has passed" do
205
+ @job.lock_exclusively!(1.minute, 'worker2').should == true
206
+ end
207
+
208
+ it "should be able to get access to the task if it was started more then max_age ago" do
209
+ @job.locked_at = 5.hours.ago
210
+ @job.save
211
+
212
+ @job.lock_exclusively! 4.hours, 'worker2'
213
+ @job.reload
214
+ @job.locked_by.should == 'worker2'
215
+ @job.locked_at.should > 1.minute.ago
216
+ end
217
+
218
+ it "should not be found by another worker" do
219
+ Delayed::Job.worker_name = 'worker2'
220
+
221
+ Delayed::Job.find_available(1, 6.minutes).length.should == 0
222
+ end
223
+
224
+ it "should be found by another worker if the time has expired" do
225
+ Delayed::Job.worker_name = 'worker2'
226
+
227
+ Delayed::Job.find_available(1, 4.minutes).length.should == 1
228
+ end
229
+
230
+ it "should be able to get exclusive access again when the worker name is the same" do
231
+ @job.lock_exclusively! 5.minutes, 'worker1'
232
+ @job.lock_exclusively! 5.minutes, 'worker1'
233
+ @job.lock_exclusively! 5.minutes, 'worker1'
234
+ end
235
+ end
236
+
237
+ context "#name" do
238
+ it "should be the class name of the job that was enqueued" do
239
+ Delayed::Job.create(:payload_object => ErrorJob.new ).name.should == 'ErrorJob'
240
+ end
241
+
242
+ it "should be the method that will be called if its a performable method object" do
243
+ Delayed::Job.send_later(:clear_locks!)
244
+ Delayed::Job.last.name.should == 'Delayed::Job.clear_locks!'
245
+
246
+ end
247
+ it "should be the instance method that will be called if its a performable method object" do
248
+ story = Story.create :text => "..."
249
+
250
+ story.send_later(:save)
251
+
252
+ Delayed::Job.last.name.should == 'Story#save'
253
+ end
254
+ end
255
+
256
+ context "worker prioritization" do
257
+
258
+ before(:each) do
259
+ Delayed::Job.max_priority = nil
260
+ Delayed::Job.min_priority = nil
261
+ end
262
+
263
+ it "should only work_off jobs that are >= min_priority" do
264
+ Delayed::Job.min_priority = -5
265
+ Delayed::Job.max_priority = 5
266
+ SimpleJob.runs.should == 0
267
+
268
+ Delayed::Job.enqueue SimpleJob.new, -10
269
+ Delayed::Job.enqueue SimpleJob.new, 0
270
+ Delayed::Job.work_off
271
+
272
+ SimpleJob.runs.should == 1
273
+ end
274
+
275
+ it "should only work_off jobs that are <= max_priority" do
276
+ Delayed::Job.min_priority = -5
277
+ Delayed::Job.max_priority = 5
278
+ SimpleJob.runs.should == 0
279
+
280
+ Delayed::Job.enqueue SimpleJob.new, 10
281
+ Delayed::Job.enqueue SimpleJob.new, 0
282
+
283
+ Delayed::Job.work_off
284
+
285
+ SimpleJob.runs.should == 1
286
+ end
287
+
288
+ end
289
+
290
+ context "when pulling jobs off the queue for processing, it" do
291
+ before(:each) do
292
+ @job = Delayed::Job.create(
293
+ :payload_object => SimpleJob.new,
294
+ :locked_by => 'worker1',
295
+ :locked_at => Delayed::Job.db_time_now - 5.minutes)
296
+ end
297
+
298
+ it "should leave the queue in a consistent state and not run the job if locking fails" do
299
+ SimpleJob.runs.should == 0
300
+ @job.stub!(:lock_exclusively!).with(any_args).once.and_return(false)
301
+ Delayed::Job.should_receive(:find_available).once.and_return([@job])
302
+ Delayed::Job.work_off(1)
303
+ SimpleJob.runs.should == 0
304
+ end
305
+
306
+ end
307
+
308
+ context "while running alongside other workers that locked jobs, it" do
309
+ before(:each) do
310
+ Delayed::Job.worker_name = 'worker1'
311
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 1.minutes))
312
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker2', :locked_at => (Delayed::Job.db_time_now - 1.minutes))
313
+ Delayed::Job.create(:payload_object => SimpleJob.new)
314
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 1.minutes))
315
+ end
316
+
317
+ it "should ingore locked jobs from other workers" do
318
+ Delayed::Job.worker_name = 'worker3'
319
+ SimpleJob.runs.should == 0
320
+ Delayed::Job.work_off
321
+ SimpleJob.runs.should == 1 # runs the one open job
322
+ end
323
+
324
+ it "should find our own jobs regardless of locks" do
325
+ Delayed::Job.worker_name = 'worker1'
326
+ SimpleJob.runs.should == 0
327
+ Delayed::Job.work_off
328
+ SimpleJob.runs.should == 3 # runs open job plus worker1 jobs that were already locked
329
+ end
330
+ end
331
+
332
+ context "while running with locked and expired jobs, it" do
333
+ before(:each) do
334
+ Delayed::Job.worker_name = 'worker1'
335
+ exp_time = Delayed::Job.db_time_now - (1.minutes + Delayed::Job::MAX_RUN_TIME)
336
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => exp_time)
337
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker2', :locked_at => (Delayed::Job.db_time_now - 1.minutes))
338
+ Delayed::Job.create(:payload_object => SimpleJob.new)
339
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 1.minutes))
340
+ end
341
+
342
+ it "should only find unlocked and expired jobs" do
343
+ Delayed::Job.worker_name = 'worker3'
344
+ SimpleJob.runs.should == 0
345
+ Delayed::Job.work_off
346
+ SimpleJob.runs.should == 2 # runs the one open job and one expired job
347
+ end
348
+
349
+ it "should ignore locks when finding our own jobs" do
350
+ Delayed::Job.worker_name = 'worker1'
351
+ SimpleJob.runs.should == 0
352
+ Delayed::Job.work_off
353
+ SimpleJob.runs.should == 3 # runs open job plus worker1 jobs
354
+ # This is useful in the case of a crash/restart on worker1, but make sure multiple workers on the same host have unique names!
355
+ end
356
+
357
+ end
358
+
359
+ end
@@ -0,0 +1,17 @@
1
+ require File.dirname(__FILE__) + '/database'
2
+
3
+ describe "A story" do
4
+
5
+ before(:all) do
6
+ @story = Story.create :text => "Once upon a time..."
7
+ end
8
+
9
+ it "should be shared" do
10
+ @story.tell.should == 'Once upon a time...'
11
+ end
12
+
13
+ it "should not return its result if it storytelling is delayed" do
14
+ @story.send_later(:tell).should_not == 'Once upon a time...'
15
+ end
16
+
17
+ end
data/tasks/jobs.rake ADDED
@@ -0,0 +1 @@
1
+ require File.join(File.dirname(__FILE__), 'tasks')
data/tasks/tasks.rb ADDED
@@ -0,0 +1,15 @@
1
+ # Re-definitions are appended to existing tasks
2
+ task :environment
3
+ task :merb_env
4
+
5
+ namespace :jobs do
6
+ desc "Clear the delayed_job queue."
7
+ task :clear => [:merb_env, :environment] do
8
+ Delayed::Job.delete_all
9
+ end
10
+
11
+ desc "Start a delayed_job worker."
12
+ task :work => [:merb_env, :environment] do
13
+ Delayed::Worker.new(:min_priority => ENV['MIN_PRIORITY'], :max_priority => ENV['MAX_PRIORITY']).start
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dburkes-delayed_job
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.7.0
5
+ platform: ruby
6
+ authors:
7
+ - "Tobias L\xC3\xBCtke"
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-11-28 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: Delated_job (or DJ) encapsulates the common pattern of asynchronously executing longer tasks in the background. It is a direct extraction from Shopify where the job table is responsible for a multitude of core tasks.
17
+ email: tobi@leetsoft.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - README.textile
24
+ files:
25
+ - MIT-LICENSE
26
+ - README.textile
27
+ - delayed_job.gemspec
28
+ - init.rb
29
+ - lib/delayed/job.rb
30
+ - lib/delayed/message_sending.rb
31
+ - lib/delayed/performable_method.rb
32
+ - lib/delayed/worker.rb
33
+ - lib/delayed_job.rb
34
+ - tasks/jobs.rake
35
+ - tasks/tasks.rb
36
+ has_rdoc: false
37
+ homepage: http://github.com/tobi/delayed_job/tree/master
38
+ post_install_message:
39
+ rdoc_options:
40
+ - --main
41
+ - README.textile
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: "0"
49
+ version:
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: "0"
55
+ version:
56
+ requirements: []
57
+
58
+ rubyforge_project:
59
+ rubygems_version: 1.2.0
60
+ signing_key:
61
+ specification_version: 2
62
+ summary: Database-backed asynchronous priority queue system -- Extracted from Shopify
63
+ test_files:
64
+ - spec/database.rb
65
+ - spec/delayed_method_spec.rb
66
+ - spec/job_spec.rb
67
+ - spec/story_spec.rb