tobi-delayed_job 1.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.
@@ -0,0 +1,81 @@
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
21
+ table.integer :attempts, :default => 0
22
+ table.text :handler
23
+ table.string :last_error
24
+ table.datetime :run_at
25
+ table.datetime :locked_at
26
+ table.datetime :failed_at
27
+ table.string :locked_by
28
+ table.timestamps
29
+ end
30
+
31
+ h2. Usage
32
+
33
+ Jobs are simple ruby objects with a method called perform. Any object which responds to perform can be stuffed into the jobs table.
34
+ Job objects are serialized to yaml so that they can later be resurrected by the job runner.
35
+
36
+ class NewsletterJob < Struct.new(:text, :emails)
37
+ def perform
38
+ emails.each { |e| NewsletterMailer.deliver_text_to_email(text, e) }
39
+ end
40
+ end
41
+
42
+ Delayed::Job.enqueue NewsletterJob.new('lorem ipsum...', Customers.find(:all).collect(&:email))
43
+
44
+ There is also a second way to get jobs in the queue: send_later.
45
+
46
+
47
+ BatchImporter.new(Shop.find(1)).send_later(:import_massive_csv, massive_csv)
48
+
49
+
50
+ 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
51
+ which are stored as their text representation and loaded from the database fresh when the job is actually run later.
52
+
53
+
54
+ h2. Running the jobs
55
+
56
+ You can invoke @rake jobs:work@ which will start working off jobs. You can cancel the rake task with @CTRL-C@.
57
+
58
+ You can also run by writing a simple @script/job_runner@, and invoking it externally:
59
+
60
+ <pre><code>
61
+ #!/usr/bin/env ruby
62
+ require File.dirname(__FILE__) + '/../config/environment'
63
+
64
+ Delayed::Worker.new.start
65
+ </code></pre>
66
+
67
+ h3. Cleaning up
68
+
69
+ You can invoke @rake jobs:clear@ to delete all jobs in the queue.
70
+
71
+ h3. Changes
72
+
73
+ * 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.
74
+
75
+ * 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.
76
+
77
+ * 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.
78
+
79
+ * 1.2.0: Added #send_later to Object for simpler job creation
80
+
81
+ * 1.0.0: Initial release
@@ -0,0 +1,40 @@
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
+ ]
34
+ s.test_files = %w[
35
+ spec/database.rb
36
+ spec/delayed_method_spec.rb
37
+ spec/job_spec.rb
38
+ spec/story_spec.rb
39
+ ]
40
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require File.dirname(__FILE__) + '/lib/delayed_job'
@@ -0,0 +1,252 @@
1
+
2
+ module Delayed
3
+
4
+ class DeserializationError < StandardError
5
+ end
6
+
7
+ class Job < ActiveRecord::Base
8
+ MAX_ATTEMPTS = 25
9
+ MAX_RUN_TIME = 4.hours
10
+ set_table_name :delayed_jobs
11
+
12
+ # By default failed jobs are destroyed after too many attempts.
13
+ # If you want to keep them around (perhaps to inspect the reason
14
+ # for the failure), set this to false.
15
+ cattr_accessor :destroy_failed_jobs
16
+ self.destroy_failed_jobs = true
17
+
18
+ # Every worker has a unique name which by default is the pid of the process.
19
+ # There are some advantages to overriding this with something which survives worker retarts:
20
+ # Workers can safely resume working on tasks which are locked by themselves. The worker will assume that it crashed before.
21
+ cattr_accessor :worker_name
22
+ self.worker_name = "host:#{Socket.gethostname} pid:#{Process.pid}" rescue "pid:#{Process.pid}"
23
+
24
+ NextTaskSQL = '(run_at <= ? AND (locked_at IS NULL OR locked_at < ?) OR (locked_by = ?)) AND failed_at IS NULL'
25
+ NextTaskOrder = 'priority DESC, run_at ASC'
26
+
27
+ ParseObjectFromYaml = /\!ruby\/\w+\:([^\s]+)/
28
+
29
+ cattr_accessor :min_priority, :max_priority
30
+ self.min_priority = nil
31
+ self.max_priority = nil
32
+
33
+ class LockError < StandardError
34
+ end
35
+
36
+ def self.clear_locks!
37
+ update_all("locked_by = null, locked_at = null", ["locked_by = ?", worker_name])
38
+ end
39
+
40
+ def failed?
41
+ failed_at
42
+ end
43
+ alias_method :failed, :failed?
44
+
45
+ def payload_object
46
+ @payload_object ||= deserialize(self['handler'])
47
+ end
48
+
49
+ def name
50
+ @name ||= begin
51
+ payload = payload_object
52
+ if payload.respond_to?(:display_name)
53
+ payload.display_name
54
+ else
55
+ payload.class.name
56
+ end
57
+ end
58
+ end
59
+
60
+ def payload_object=(object)
61
+ self['handler'] = object.to_yaml
62
+ end
63
+
64
+ def reschedule(message, backtrace = [], time = nil)
65
+ if self.attempts < MAX_ATTEMPTS
66
+ time ||= Job.db_time_now + (attempts ** 4) + 5
67
+
68
+ self.attempts += 1
69
+ self.run_at = time
70
+ self.last_error = message + "\n" + backtrace.join("\n")
71
+ self.unlock
72
+ save!
73
+ else
74
+ logger.info "* [JOB] PERMANENTLY removing #{self.name} because of #{attempts} consequetive failures."
75
+ destroy_failed_jobs ? destroy : update_attribute(:failed_at, Time.now)
76
+ end
77
+ end
78
+
79
+ def self.enqueue(*args, &block)
80
+ object = block_given? ? EvaledJob.new(&block) : args.shift
81
+
82
+ unless object.respond_to?(:perform) || block_given?
83
+ raise ArgumentError, 'Cannot enqueue items which do not respond to perform'
84
+ end
85
+
86
+ priority = args.first || 0
87
+ run_at = args.second
88
+
89
+ Job.create(:payload_object => object, :priority => priority.to_i, :run_at => run_at)
90
+ end
91
+
92
+ def self.find_available(limit = 5, max_run_time = MAX_RUN_TIME)
93
+
94
+ time_now = db_time_now
95
+
96
+ sql = NextTaskSQL.dup
97
+
98
+ conditions = [time_now, time_now - max_run_time, worker_name]
99
+
100
+ if self.min_priority
101
+ sql << ' AND (priority >= ?)'
102
+ conditions << min_priority
103
+ end
104
+
105
+ if self.max_priority
106
+ sql << ' AND (priority <= ?)'
107
+ conditions << max_priority
108
+ end
109
+
110
+ conditions.unshift(sql)
111
+
112
+ records = ActiveRecord::Base.silence do
113
+ find(:all, :conditions => conditions, :order => NextTaskOrder, :limit => limit)
114
+ end
115
+
116
+ records.sort { rand() }
117
+ end
118
+
119
+ # Get the payload of the next job we can get an exclusive lock on.
120
+ # If no jobs are left we return nil
121
+ def self.reserve(max_run_time = MAX_RUN_TIME, &block)
122
+
123
+ # We get up to 5 jobs from the db. In face we cannot get exclusive access to a job we try the next.
124
+ # this leads to a more even distribution of jobs across the worker processes
125
+ find_available(5, max_run_time).each do |job|
126
+ begin
127
+ logger.info "* [JOB] aquiring lock on #{job.name}"
128
+ job.lock_exclusively!(max_run_time, worker_name)
129
+ runtime = Benchmark.realtime do
130
+ invoke_job(job.payload_object, &block)
131
+ job.destroy
132
+ end
133
+ logger.info "* [JOB] #{job.name} completed after %.4f" % runtime
134
+
135
+ return job
136
+ rescue LockError
137
+ # We did not get the lock, some other worker process must have
138
+ logger.warn "* [JOB] failed to aquire exclusive lock for #{job.name}"
139
+ rescue StandardError => e
140
+ job.reschedule e.message, e.backtrace
141
+ log_exception(job, e)
142
+ return job
143
+ end
144
+ end
145
+
146
+ nil
147
+ end
148
+
149
+ # This method is used internally by reserve method to ensure exclusive access
150
+ # to the given job. It will rise a LockError if it cannot get this lock.
151
+ def lock_exclusively!(max_run_time, worker = worker_name)
152
+ now = self.class.db_time_now
153
+ affected_rows = if locked_by != worker
154
+ # We don't own this job so we will update the locked_by name and the locked_at
155
+ 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)])
156
+ else
157
+ # We already own this job, this may happen if the job queue crashes.
158
+ # Simply resume and update the locked_at
159
+ self.class.update_all(["locked_at = ?", now], ["id = ? and locked_by = ?", id, worker])
160
+ end
161
+ raise LockError.new("Attempted to aquire exclusive lock failed") unless affected_rows == 1
162
+
163
+ self.locked_at = now
164
+ self.locked_by = worker
165
+ end
166
+
167
+ def unlock
168
+ self.locked_at = nil
169
+ self.locked_by = nil
170
+ end
171
+
172
+ # This is a good hook if you need to report job processing errors in additional or different ways
173
+ def self.log_exception(job, error)
174
+ logger.error "* [JOB] #{job.name} failed with #{error.class.name}: #{error.message} - #{job.attempts} failed attempts"
175
+ logger.error(error)
176
+ end
177
+
178
+ def self.work_off(num = 100)
179
+ success, failure = 0, 0
180
+
181
+ num.times do
182
+ job = self.reserve do |j|
183
+ begin
184
+ j.perform
185
+ success += 1
186
+ rescue
187
+ failure += 1
188
+ raise
189
+ end
190
+ end
191
+
192
+ break if job.nil?
193
+ end
194
+
195
+ return [success, failure]
196
+ end
197
+
198
+ # Moved into its own method so that new_relic can trace it.
199
+ def self.invoke_job(job, &block)
200
+ block.call(job)
201
+ end
202
+
203
+ private
204
+
205
+ def deserialize(source)
206
+ handler = YAML.load(source) rescue nil
207
+
208
+ unless handler.respond_to?(:perform)
209
+ if handler.nil? && source =~ ParseObjectFromYaml
210
+ handler_class = $1
211
+ end
212
+ attempt_to_load(handler_class || handler.class)
213
+ handler = YAML.load(source)
214
+ end
215
+
216
+ return handler if handler.respond_to?(:perform)
217
+
218
+ raise DeserializationError,
219
+ 'Job failed to load: Unknown handler. Try to manually require the appropiate file.'
220
+ rescue TypeError, LoadError, NameError => e
221
+ raise DeserializationError,
222
+ "Job failed to load: #{e.message}. Try to manually require the required file."
223
+ end
224
+
225
+ # Constantize the object so that ActiveSupport can attempt
226
+ # its auto loading magic. Will raise LoadError if not successful.
227
+ def attempt_to_load(klass)
228
+ klass.constantize
229
+ end
230
+
231
+ def self.db_time_now
232
+ (ActiveRecord::Base.default_timezone == :utc) ? Time.now.utc : Time.now
233
+ end
234
+
235
+ protected
236
+
237
+ def before_save
238
+ self.run_at ||= self.class.db_time_now
239
+ end
240
+
241
+ end
242
+
243
+ class EvaledJob
244
+ def initialize
245
+ @job = yield
246
+ end
247
+
248
+ def perform
249
+ eval(@job)
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,7 @@
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
+ end
7
+ 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,50 @@
1
+ module Delayed
2
+ class Worker
3
+ SLEEP = 5
4
+
5
+ cattr_accessor :logger
6
+ self.logger = RAILS_DEFAULT_LOGGER if const_defined?(:RAILS_DEFAULT_LOGGER)
7
+
8
+ def initialize(options={})
9
+ @quiet = options[:quiet]
10
+ Delayed::Job.min_priority = options[:min_priority] if options.has_key?(:min_priority)
11
+ Delayed::Job.max_priority = options[:max_priority] if options.has_key?(:max_priority)
12
+ end
13
+
14
+ def start
15
+ say "*** Starting job worker #{Delayed::Job.worker_name}"
16
+
17
+ trap('TERM') { say 'Exiting...'; $exit = true }
18
+ trap('INT') { say 'Exiting...'; $exit = true }
19
+
20
+ loop do
21
+ result = nil
22
+
23
+ realtime = Benchmark.realtime do
24
+ result = Delayed::Job.work_off
25
+ end
26
+
27
+ count = result.sum
28
+
29
+ break if $exit
30
+
31
+ if count.zero?
32
+ sleep(SLEEP)
33
+ else
34
+ say "#{count} jobs processed at %.4f j/s, %d failed ..." % [count / realtime, result.last]
35
+ end
36
+
37
+ break if $exit
38
+ end
39
+
40
+ ensure
41
+ Delayed::Job.clear_locks!
42
+ end
43
+
44
+ def say(text)
45
+ puts text unless @quiet
46
+ logger.info text if logger
47
+ end
48
+
49
+ end
50
+ end
@@ -0,0 +1,6 @@
1
+ require File.dirname(__FILE__) + '/delayed/message_sending'
2
+ require File.dirname(__FILE__) + '/delayed/performable_method'
3
+ require File.dirname(__FILE__) + '/delayed/job'
4
+ require File.dirname(__FILE__) + '/delayed/worker'
5
+
6
+ Object.send(:include, Delayed::MessageSending)
@@ -0,0 +1,39 @@
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
+ end
@@ -0,0 +1,117 @@
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
+ output = nil
82
+
83
+ Delayed::Job.reserve do |e|
84
+ output = e.perform
85
+ end
86
+
87
+ output.should == true
88
+
89
+ end
90
+
91
+ it "should store the object as string if its an active record" do
92
+ story = Story.create :text => 'Once upon...'
93
+ story.send_later(:tell)
94
+
95
+ job = Delayed::Job.find(:first)
96
+ job.payload_object.class.should == Delayed::PerformableMethod
97
+ job.payload_object.object.should == "AR:Story:#{story.id}"
98
+ job.payload_object.method.should == :tell
99
+ job.payload_object.args.should == []
100
+ job.payload_object.perform.should == 'Once upon...'
101
+ end
102
+
103
+ it "should store arguments as string if they an active record" do
104
+
105
+ story = Story.create :text => 'Once upon...'
106
+
107
+ reader = StoryReader.new
108
+ reader.send_later(:read, story)
109
+
110
+ job = Delayed::Job.find(:first)
111
+ job.payload_object.class.should == Delayed::PerformableMethod
112
+ job.payload_object.method.should == :read
113
+ job.payload_object.args.should == ["AR:Story:#{story.id}"]
114
+ job.payload_object.perform.should == 'Epilog: Once upon...'
115
+ end
116
+
117
+ end
@@ -0,0 +1,311 @@
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
+
19
+ end
20
+
21
+ describe Delayed::Job do
22
+ before do
23
+ Delayed::Job.max_priority = nil
24
+ Delayed::Job.min_priority = nil
25
+
26
+ Delayed::Job.delete_all
27
+ end
28
+
29
+ before(:each) do
30
+ SimpleJob.runs = 0
31
+ end
32
+
33
+ it "should set run_at automatically if not set" do
34
+ Delayed::Job.create(:payload_object => ErrorJob.new ).run_at.should_not == nil
35
+ end
36
+
37
+ it "should not set run_at automatically if already set" do
38
+ later = 5.minutes.from_now
39
+ Delayed::Job.create(:payload_object => ErrorJob.new, :run_at => later).run_at.should == later
40
+ end
41
+
42
+ it "should raise ArgumentError when handler doesn't respond_to :perform" do
43
+ lambda { Delayed::Job.enqueue(Object.new) }.should raise_error(ArgumentError)
44
+ end
45
+
46
+ it "should increase count after enqueuing items" do
47
+ Delayed::Job.enqueue SimpleJob.new
48
+ Delayed::Job.count.should == 1
49
+ end
50
+
51
+ it "should be able to set priority when enqueuing items" do
52
+ Delayed::Job.enqueue SimpleJob.new, 5
53
+ Delayed::Job.first.priority.should == 5
54
+ end
55
+
56
+ it "should be able to set run_at when enqueuing items" do
57
+ later = 5.minutes.from_now
58
+ Delayed::Job.enqueue SimpleJob.new, 5, later
59
+
60
+ # use be close rather than equal to because millisecond values cn be lost in DB round trip
61
+ Delayed::Job.first.run_at.should be_close(later, 1)
62
+ end
63
+
64
+ it "should call perform on jobs when running work_off" do
65
+ SimpleJob.runs.should == 0
66
+
67
+ Delayed::Job.enqueue SimpleJob.new
68
+ Delayed::Job.work_off
69
+
70
+ SimpleJob.runs.should == 1
71
+ end
72
+
73
+
74
+ it "should work with eval jobs" do
75
+ $eval_job_ran = false
76
+
77
+ Delayed::Job.enqueue do <<-JOB
78
+ $eval_job_ran = true
79
+ JOB
80
+ end
81
+
82
+ Delayed::Job.work_off
83
+
84
+ $eval_job_ran.should == true
85
+ end
86
+
87
+ it "should work with jobs in modules" do
88
+ M::ModuleJob.runs.should == 0
89
+
90
+ Delayed::Job.enqueue M::ModuleJob.new
91
+ Delayed::Job.work_off
92
+
93
+ M::ModuleJob.runs.should == 1
94
+ end
95
+
96
+ it "should re-schedule by about 1 second at first and increment this more and more minutes when it fails to execute properly" do
97
+ Delayed::Job.enqueue ErrorJob.new
98
+ Delayed::Job.work_off(1)
99
+
100
+ job = Delayed::Job.find(:first)
101
+
102
+ job.last_error.should =~ /did not work/
103
+ job.last_error.should =~ /job_spec.rb:10:in `perform'/
104
+ job.attempts.should == 1
105
+
106
+ job.run_at.should > Delayed::Job.db_time_now - 10.minutes
107
+ job.run_at.should < Delayed::Job.db_time_now + 10.minutes
108
+ end
109
+
110
+ it "should raise an DeserializationError when the job class is totally unknown" do
111
+
112
+ job = Delayed::Job.new
113
+ job['handler'] = "--- !ruby/object:JobThatDoesNotExist {}"
114
+
115
+ lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError)
116
+ end
117
+
118
+ it "should try to load the class when it is unknown at the time of the deserialization" do
119
+ job = Delayed::Job.new
120
+ job['handler'] = "--- !ruby/object:JobThatDoesNotExist {}"
121
+
122
+ job.should_receive(:attempt_to_load).with('JobThatDoesNotExist').and_return(true)
123
+
124
+ lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError)
125
+ end
126
+
127
+ it "should try include the namespace when loading unknown objects" do
128
+ job = Delayed::Job.new
129
+ job['handler'] = "--- !ruby/object:Delayed::JobThatDoesNotExist {}"
130
+ job.should_receive(:attempt_to_load).with('Delayed::JobThatDoesNotExist').and_return(true)
131
+ lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError)
132
+ end
133
+
134
+ it "should also try to load structs when they are unknown (raises TypeError)" do
135
+ job = Delayed::Job.new
136
+ job['handler'] = "--- !ruby/struct:JobThatDoesNotExist {}"
137
+
138
+ job.should_receive(:attempt_to_load).with('JobThatDoesNotExist').and_return(true)
139
+
140
+ lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError)
141
+ end
142
+
143
+ it "should try include the namespace when loading unknown structs" do
144
+ job = Delayed::Job.new
145
+ job['handler'] = "--- !ruby/struct:Delayed::JobThatDoesNotExist {}"
146
+
147
+ job.should_receive(:attempt_to_load).with('Delayed::JobThatDoesNotExist').and_return(true)
148
+ lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError)
149
+ end
150
+
151
+ it "should be failed if it failed more than MAX_ATTEMPTS times and we don't want to destroy jobs" do
152
+ default = Delayed::Job.destroy_failed_jobs
153
+ Delayed::Job.destroy_failed_jobs = false
154
+
155
+ @job = Delayed::Job.create :payload_object => SimpleJob.new, :attempts => 50
156
+ @job.reload.failed_at.should == nil
157
+ @job.reschedule 'FAIL'
158
+ @job.reload.failed_at.should_not == nil
159
+
160
+ Delayed::Job.destroy_failed_jobs = default
161
+ end
162
+
163
+ it "should be destroyed if it failed more than MAX_ATTEMPTS times and we want to destroy jobs" do
164
+ default = Delayed::Job.destroy_failed_jobs
165
+ Delayed::Job.destroy_failed_jobs = true
166
+
167
+ @job = Delayed::Job.create :payload_object => SimpleJob.new, :attempts => 50
168
+ @job.should_receive(:destroy)
169
+ @job.reschedule 'FAIL'
170
+
171
+ Delayed::Job.destroy_failed_jobs = default
172
+ end
173
+
174
+ it "should never find failed jobs" do
175
+ @job = Delayed::Job.create :payload_object => SimpleJob.new, :attempts => 50, :failed_at => Time.now
176
+ Delayed::Job.find_available(1).length.should == 0
177
+ end
178
+
179
+ context "when another worker is already performing an task, it" do
180
+
181
+ before :each do
182
+ Delayed::Job.worker_name = 'worker1'
183
+ @job = Delayed::Job.create :payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => Delayed::Job.db_time_now - 5.minutes
184
+ end
185
+
186
+ it "should not allow a second worker to get exclusive access" do
187
+ lambda { @job.lock_exclusively! 4.hours, 'worker2' }.should raise_error(Delayed::Job::LockError)
188
+ end
189
+
190
+ it "should allow a second worker to get exclusive access if the timeout has passed" do
191
+ lambda { @job.lock_exclusively! 1.minute, 'worker2' }.should_not raise_error(Delayed::Job::LockError)
192
+ end
193
+
194
+ it "should be able to get access to the task if it was started more then max_age ago" do
195
+ @job.locked_at = 5.hours.ago
196
+ @job.save
197
+
198
+ @job.lock_exclusively! 4.hours, 'worker2'
199
+ @job.reload
200
+ @job.locked_by.should == 'worker2'
201
+ @job.locked_at.should > 1.minute.ago
202
+ end
203
+
204
+ it "should not be found by another worker" do
205
+ Delayed::Job.worker_name = 'worker2'
206
+
207
+ Delayed::Job.find_available(1, 6.minutes).length.should == 0
208
+ end
209
+
210
+ it "should be found by another worker if the time has expired" do
211
+ Delayed::Job.worker_name = 'worker2'
212
+
213
+ Delayed::Job.find_available(1, 4.minutes).length.should == 1
214
+ end
215
+
216
+ it "should be able to get exclusive access again when the worker name is the same" do
217
+ @job.lock_exclusively! 5.minutes, 'worker1'
218
+ @job.lock_exclusively! 5.minutes, 'worker1'
219
+ @job.lock_exclusively! 5.minutes, 'worker1'
220
+ end
221
+ end
222
+
223
+ context "#name" do
224
+ it "should be the class name of the job that was enqueued" do
225
+ Delayed::Job.create(:payload_object => ErrorJob.new ).name.should == 'ErrorJob'
226
+ end
227
+
228
+ it "should be the method that will be called if its a performable method object" do
229
+ Delayed::Job.send_later(:clear_locks!)
230
+ Delayed::Job.last.name.should == 'Delayed::Job.clear_locks!'
231
+
232
+ end
233
+ it "should be the instance method that will be called if its a performable method object" do
234
+ story = Story.create :text => "..."
235
+
236
+ story.send_later(:save)
237
+
238
+ Delayed::Job.last.name.should == 'Story#save'
239
+ end
240
+ end
241
+
242
+ context "worker prioritization" do
243
+
244
+ before(:each) do
245
+ Delayed::Job.max_priority = nil
246
+ Delayed::Job.min_priority = nil
247
+ end
248
+
249
+ it "should only work_off jobs that are >= min_priority" do
250
+ Delayed::Job.min_priority = -5
251
+ Delayed::Job.max_priority = 5
252
+ SimpleJob.runs.should == 0
253
+
254
+ Delayed::Job.enqueue SimpleJob.new, -10
255
+ Delayed::Job.enqueue SimpleJob.new, 0
256
+ Delayed::Job.work_off
257
+
258
+ SimpleJob.runs.should == 1
259
+ end
260
+
261
+ it "should only work_off jobs that are <= max_priority" do
262
+ Delayed::Job.min_priority = -5
263
+ Delayed::Job.max_priority = 5
264
+ SimpleJob.runs.should == 0
265
+
266
+ Delayed::Job.enqueue SimpleJob.new, 10
267
+ Delayed::Job.enqueue SimpleJob.new, 0
268
+
269
+ Delayed::Job.work_off
270
+
271
+ SimpleJob.runs.should == 1
272
+ end
273
+
274
+ end
275
+
276
+ context "when pulling jobs off the queue for processing, it" do
277
+ before(:each) do
278
+ @job = Delayed::Job.create(
279
+ :payload_object => SimpleJob.new,
280
+ :locked_by => 'worker1',
281
+ :locked_at => Delayed::Job.db_time_now - 5.minutes)
282
+ end
283
+
284
+ it "should leave the queue in a consistent state and not run the job if locking fails" do
285
+ SimpleJob.runs.should == 0
286
+ @job.stub!(:lock_exclusively!).with(any_args).once.and_raise(Delayed::Job::LockError)
287
+ Delayed::Job.should_receive(:find_available).once.and_return([@job])
288
+ Delayed::Job.work_off(1)
289
+ SimpleJob.runs.should == 0
290
+ end
291
+
292
+ end
293
+
294
+ context "while running alongside other workers with enqueued jobs, it" do
295
+ before(:each) do
296
+ Delayed::Job.worker_name = 'worker1'
297
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 3.minutes))
298
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker2', :locked_at => (Delayed::Job.db_time_now - 11.minutes))
299
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 2.minutes))
300
+ end
301
+
302
+ it "should only find jobs if the lock has expired reguardless of the worker" do
303
+ SimpleJob.runs.should == 0
304
+ Delayed::Job.work_off(5)
305
+ SimpleJob.runs.should == 2
306
+ Delayed::Job.find_available(5, 10.minutes).length.should == 1
307
+ end
308
+
309
+ end
310
+
311
+ 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
@@ -0,0 +1,11 @@
1
+ namespace :jobs do
2
+ desc "Clear the delayed_job queue."
3
+ task :clear => :environment do
4
+ Delayed::Job.delete_all
5
+ end
6
+
7
+ desc "Start a delayed_job worker."
8
+ task :work => :environment do
9
+ Delayed::Worker.new(:min_priority => ENV['MIN_PRIORITY'], :max_priority => ENV['MAX_PRIORITY']).start
10
+ end
11
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tobi-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
+ has_rdoc: false
36
+ homepage: http://github.com/tobi/delayed_job/tree/master
37
+ post_install_message:
38
+ rdoc_options:
39
+ - --main
40
+ - README.textile
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: "0"
48
+ version:
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: "0"
54
+ version:
55
+ requirements: []
56
+
57
+ rubyforge_project:
58
+ rubygems_version: 1.2.0
59
+ signing_key:
60
+ specification_version: 2
61
+ summary: Database-backed asynchronous priority queue system -- Extracted from Shopify
62
+ test_files:
63
+ - spec/database.rb
64
+ - spec/delayed_method_spec.rb
65
+ - spec/job_spec.rb
66
+ - spec/story_spec.rb