dm-delayed-job 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ *.gem
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,104 @@
1
+ h1. Delayed::Job
2
+
3
+ Delayed_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. DataMapper
16
+
17
+ This library is DataMapper specific without backward compatibility with original DJ
18
+
19
+ h2. Setup
20
+
21
+ On failure, the job is scheduled again in 5 seconds + N ** 4, where N is the number of retries.
22
+
23
+ The default @MAX_ATTEMPTS@ is @25@. After this, the job either deleted (default), or left in the database with "failed_at" set.
24
+ With the default of 25 attempts, the last retry will be 20 days later, with the last interval being almost 100 hours.
25
+
26
+ 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
27
+ make sure your job doesn't exceed this time. You should set this to the longest time you think the job could take.
28
+
29
+ By default, it will delete failed jobs (and it always deletes successful jobs). If you want to keep failed jobs, set
30
+ @Delayed::Job.destroy_failed_jobs = false@. The failed jobs will be marked with non-null failed_at.
31
+
32
+ Here is an example of changing job parameters in Rails:
33
+
34
+ <pre><code>
35
+ # config/initializers/delayed_job_config.rb
36
+ Delayed::Job.destroy_failed_jobs = false
37
+ silence_warnings do
38
+ Delayed::Job.const_set("MAX_ATTEMPTS", 3)
39
+ Delayed::Job.const_set("MAX_RUN_TIME", 5.minutes)
40
+ end
41
+ </code></pre>
42
+
43
+ Note: If your error messages are long, consider changing last_error field to a :text instead of a :string (255 character limit).
44
+
45
+
46
+ h2. Usage
47
+
48
+ Jobs are simple ruby objects with a method called perform. Any object which responds to perform can be stuffed into the jobs table.
49
+ Job objects are serialized to yaml so that they can later be resurrected by the job runner.
50
+
51
+ <pre><code>
52
+ class NewsletterJob < Struct.new(:text, :emails)
53
+ def perform
54
+ emails.each { |e| NewsletterMailer.deliver_text_to_email(text, e) }
55
+ end
56
+ end
57
+
58
+ Delayed::Job.enqueue NewsletterJob.new('lorem ipsum...', Customers.find(:all).collect(&:email))
59
+ </code></pre>
60
+
61
+ There is also a second way to get jobs in the queue: send_later.
62
+
63
+ <pre><code>
64
+ BatchImporter.new(Shop.find(1)).send_later(:import_massive_csv, massive_csv)
65
+ </code></pre>
66
+
67
+ 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
68
+ which are stored as their text representation and loaded from the database fresh when the job is actually run later.
69
+
70
+
71
+ h2. Running the jobs
72
+
73
+ You can invoke @rake jobs:work@ which will start working off jobs. You can cancel the rake task with @CTRL-C@.
74
+
75
+ You can also run by writing a simple @script/job_runner@, and invoking it externally:
76
+
77
+ <pre><code>
78
+ #!/usr/bin/env ruby
79
+ require File.dirname(__FILE__) + '/../config/environment'
80
+
81
+ Delayed::Worker.new.start
82
+ </code></pre>
83
+
84
+ 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
85
+ run multiple workers on per computer, but you must give each one a unique name. (TODO: put in an example)
86
+ Keep in mind that each worker will check the database at least every 5 seconds.
87
+
88
+ Note: The rake task will exit if the database has any network connectivity problems.
89
+
90
+ h3. Cleaning up
91
+
92
+ You can invoke @rake jobs:clear@ to delete all jobs in the queue.
93
+
94
+ h3. Changes
95
+
96
+ * 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.
97
+
98
+ * 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.
99
+
100
+ * 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.
101
+
102
+ * 1.2.0: Added #send_later to Object for simpler job creation
103
+
104
+ * 1.0.0: Initial release
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ require 'spec/rake/spectask'
2
+ desc "Run specs, run a specific spec with TASK=spec/path_to_spec.rb."
3
+ task :spec => [ "spec:default" ]
4
+
5
+ namespace :spec do
6
+ OPTS_FILENAME = "./spec/spec.opts"
7
+ if File.exist?(OPTS_FILENAME)
8
+ SPEC_OPTS = ["--options", OPTS_FILENAME]
9
+ else
10
+ SPEC_OPTS = ["--color", "--format", "specdoc"]
11
+ end
12
+
13
+ Spec::Rake::SpecTask.new('default') do |t|
14
+ t.spec_opts = SPEC_OPTS
15
+ if(ENV['TASK'])
16
+ t.spec_files = [ENV['TASK']]
17
+ else
18
+ t.spec_files = Dir['spec/*_spec.rb'].sort
19
+ end
20
+ end
21
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.0
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require File.dirname(__FILE__) + '/lib/dm-delayed-job'
@@ -0,0 +1,313 @@
1
+ # Prevent problem with extlib 0.9.11:
2
+ # extlib-0.9.11 NameError: method `to_time' not defined in Time
3
+ # http://groups.google.com/group/datamapper/browse_thread/thread/98f9a69dca23d80
4
+ gem 'extlib', '>= 0.9.12'
5
+ require 'extlib'
6
+
7
+ require 'dm-aggregates'
8
+ require 'dm-timestamps'
9
+
10
+ module Delayed
11
+
12
+ class DeserializationError < StandardError
13
+ end
14
+
15
+ # A job object that is persisted to the database.
16
+ # Contains the work object as a YAML field.
17
+ class Job
18
+ include DataMapper::Resource
19
+
20
+ MAX_ATTEMPTS = 25
21
+ MAX_RUN_TIME = 4.hours
22
+ storage_names[:default]='delayed_jobs'
23
+
24
+ # By default failed jobs are destroyed after too many attempts.
25
+ # If you want to keep them around (perhaps to inspect the reason
26
+ # for the failure), set this to false.
27
+ cattr_accessor :destroy_failed_jobs
28
+ self.destroy_failed_jobs = true
29
+
30
+ # Every worker has a unique name which by default is the pid of the process.
31
+ # There are some advantages to overriding this with something which survives worker retarts:
32
+ # Workers can safely resume working on tasks which are locked by themselves. The worker will assume that it crashed before.
33
+ cattr_accessor :worker_name
34
+ self.worker_name = "host:#{Socket.gethostname} pid:#{Process.pid}" rescue "pid:#{Process.pid}"
35
+
36
+ NextTaskSQL = '(run_at <= ? AND (locked_at IS NULL OR locked_at < ?) OR (locked_by = ?)) AND failed_at IS NULL'
37
+ NextTaskOrder = [:priority.desc, :run_at.asc]
38
+ ParseObjectFromYaml = /\!ruby\/\w+\:([^\s]+)/
39
+
40
+ cattr_accessor :min_priority, :max_priority
41
+ self.min_priority = nil
42
+ self.max_priority = nil
43
+
44
+ property :id, Serial
45
+ property :priority, Integer, :default => 0
46
+ property :attempts, Integer, :default => 0
47
+ property :handler, Text, :lazy => false
48
+ property :last_error, Text, :lazy => false
49
+ property :run_at, Time
50
+ property :locked_at, Time, :required => false
51
+ property :locked_by, String
52
+ property :failed_at, Time, :required => false
53
+ property :created_at, DateTime
54
+ property :updated_at, DateTime
55
+
56
+ def self.update_all(with, from)
57
+ repository(:default).adapter.execute("UPDATE #{storage_names[:default]} SET #{Array(with)[0]} WHERE #{Array(from)[0]}", *Array(with)[1..-1].concat(Array(from)[1..-1])).affected_rows
58
+ end
59
+
60
+ def self.delete_all
61
+ all.destroy!
62
+ end
63
+
64
+ def self.last
65
+ all.last
66
+ end
67
+
68
+ def logger
69
+ DataMapper.logger
70
+ end
71
+
72
+ def self.logger
73
+ DataMapper.logger
74
+ end
75
+
76
+ # When a worker is exiting, make sure we don't have any locked jobs.
77
+ def self.clear_locks!
78
+ update_all("locked_by = null, locked_at = null", ["locked_by = ?", worker_name])
79
+ end
80
+
81
+ def failed?
82
+ failed_at
83
+ end
84
+ alias_method :failed, :failed?
85
+
86
+ def payload_object
87
+ @payload_object ||= deserialize(self.handler)
88
+ end
89
+
90
+ def name
91
+ @name ||= begin
92
+ payload = payload_object
93
+ if payload.respond_to?(:display_name)
94
+ payload.display_name
95
+ else
96
+ payload.class.name
97
+ end
98
+ end
99
+ end
100
+
101
+ def payload_object=(object)
102
+ self.handler = object.to_yaml
103
+ end
104
+
105
+ # Reschedule the job in the future (when a job fails).
106
+ # Uses an exponential scale depending on the number of failed attempts.
107
+ def reschedule(message, backtrace = [], time = nil)
108
+ if self.attempts < MAX_ATTEMPTS
109
+ time ||= Job.db_time_now + (attempts ** 4) + 5
110
+
111
+ self.attempts += 1
112
+ self.run_at = time
113
+ self.last_error = message + "\n" + backtrace.join("\n")
114
+ self.unlock
115
+ save
116
+ else
117
+ logger.info "* [JOB] PERMANENTLY removing #{self.name} because of #{attempts} consequetive failures."
118
+ destroy_failed_jobs ? destroy : update(:failed_at => Delayed::Job.db_time_now)
119
+ end
120
+ end
121
+
122
+
123
+ # Try to run one job. Returns true/false (work done/work failed) or nil if job can't be locked.
124
+ def run_with_lock(max_run_time, worker_name)
125
+ logger.info "* [JOB] aquiring lock on #{name}"
126
+ unless lock_exclusively!(max_run_time, worker_name)
127
+ # We did not get the lock, some other worker process must have
128
+ logger.warn "* [JOB] failed to aquire exclusive lock for #{name}"
129
+ return nil # no work done
130
+ end
131
+
132
+ begin
133
+ runtime = Benchmark.realtime do
134
+ invoke_job # TODO: raise error if takes longer than max_run_time
135
+ destroy
136
+ end
137
+ # TODO: warn if runtime > max_run_time ?
138
+ logger.info "* [JOB] #{name} completed after %.4f" % runtime
139
+ return true # did work
140
+ rescue Exception => e
141
+ reschedule e.message, e.backtrace
142
+ log_exception(e)
143
+ return false # work failed
144
+ end
145
+ end
146
+
147
+ # Add a job to the queue
148
+ def self.enqueue(*args, &block)
149
+ object = block_given? ? EvaledJob.new(&block) : args.shift
150
+
151
+ unless object.respond_to?(:perform) || block_given?
152
+ raise ArgumentError, 'Cannot enqueue items which do not respond to perform'
153
+ end
154
+
155
+ priority = args.first || 0
156
+ run_at = args[1]
157
+
158
+ Job.create(:payload_object => object, :priority => priority.to_i, :run_at => run_at)
159
+ end
160
+
161
+ # Find a few candidate jobs to run (in case some immediately get locked by others).
162
+ # Return in random order prevent everyone trying to do same head job at once.
163
+ def self.find_available(limit = 5, max_run_time = MAX_RUN_TIME)
164
+
165
+ time_now = db_time_now
166
+
167
+ sql = NextTaskSQL.dup
168
+
169
+ conditions = [time_now, time_now - max_run_time, worker_name]
170
+
171
+ if self.min_priority
172
+ sql << ' AND (priority >= ?)'
173
+ conditions << min_priority
174
+ end
175
+
176
+ if self.max_priority
177
+ sql << ' AND (priority <= ?)'
178
+ conditions << max_priority
179
+ end
180
+
181
+ conditions.unshift(sql)
182
+
183
+ orig, DataMapper.logger.level = DataMapper.logger.level, :error
184
+ records = all(:conditions => conditions, :order => Delayed::Job::NextTaskOrder, :limit => limit)
185
+ DataMapper.logger.level = orig
186
+
187
+ records.sort_by { rand() }
188
+ end
189
+
190
+ # Run the next job we can get an exclusive lock on.
191
+ # If no jobs are left we return nil
192
+ def self.reserve_and_run_one_job(max_run_time = MAX_RUN_TIME)
193
+
194
+ # We get up to 5 jobs from the db. In case we cannot get exclusive access to a job we try the next.
195
+ # this leads to a more even distribution of jobs across the worker processes
196
+ find_available(5, max_run_time).each do |job|
197
+ t = job.run_with_lock(max_run_time, worker_name)
198
+ return t unless t == nil # return if we did work (good or bad)
199
+ end
200
+
201
+ nil # we didn't do any work, all 5 were not lockable
202
+ end
203
+
204
+ # Lock this job for this worker.
205
+ # Returns true if we have the lock, false otherwise.
206
+ def lock_exclusively!(max_run_time, worker = worker_name)
207
+ now = self.class.db_time_now
208
+ affected_rows = if locked_by != worker
209
+ # We don't own this job so we will update the locked_by name and the locked_at
210
+ 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)])
211
+ else
212
+ # We already own this job, this may happen if the job queue crashes.
213
+ # Simply resume and update the locked_at
214
+ self.class.update_all(["locked_at = ?", now], ["id = ? and locked_by = ?", id, worker])
215
+ end
216
+ if affected_rows == 1
217
+ self.locked_at = now
218
+ self.locked_by = worker
219
+ return true
220
+ else
221
+ return false
222
+ end
223
+ end
224
+
225
+ # Unlock this job (note: not saved to DB)
226
+ def unlock
227
+ self.locked_at = nil
228
+ self.locked_by = nil
229
+ end
230
+
231
+ # This is a good hook if you need to report job processing errors in additional or different ways
232
+ def log_exception(error)
233
+ logger.error "* [JOB] #{name} failed with #{error.class.name}: #{error.message} - #{attempts} failed attempts"
234
+ logger.error(error)
235
+ end
236
+
237
+ # Do num jobs and return stats on success/failure.
238
+ # Exit early if interrupted.
239
+ def self.work_off(num = 100)
240
+ success, failure = 0, 0
241
+
242
+ num.times do
243
+ case self.reserve_and_run_one_job
244
+ when true
245
+ success += 1
246
+ when false
247
+ failure += 1
248
+ else
249
+ break # leave if no work could be done
250
+ end
251
+ break if $exit # leave if we're exiting
252
+ end
253
+
254
+ return [success, failure]
255
+ end
256
+
257
+ # Moved into its own method so that new_relic can trace it.
258
+ def invoke_job
259
+ payload_object.perform
260
+ end
261
+
262
+ private
263
+
264
+ def deserialize(source)
265
+ handler = YAML.load(source) rescue nil
266
+
267
+ unless handler.respond_to?(:perform)
268
+ if handler.nil? && source =~ ParseObjectFromYaml
269
+ handler_class = $1
270
+ end
271
+ attempt_to_load(handler_class || handler.class)
272
+ handler = YAML.load(source)
273
+ end
274
+
275
+ return handler if handler.respond_to?(:perform)
276
+
277
+ raise DeserializationError,
278
+ 'Job failed to load: Unknown handler. Try to manually require the appropiate file.'
279
+ rescue TypeError, LoadError, NameError => e
280
+ raise DeserializationError,
281
+ "Job failed to load: #{e.message}. Try to manually require the required file."
282
+ end
283
+
284
+ # Constantize the object so that ActiveSupport can attempt
285
+ # its auto loading magic. Will raise LoadError if not successful.
286
+ def attempt_to_load(klass)
287
+ klass.constantize
288
+ end
289
+
290
+ # Get the current time (GMT or local depending on DB)
291
+ # Note: This does not ping the DB to get the time, so all your clients
292
+ # must have syncronized clocks.
293
+ def self.db_time_now
294
+ Time.now
295
+ end
296
+
297
+
298
+ before(:save) { self.run_at ||= self.class.db_time_now }
299
+ end
300
+ end
301
+
302
+ class LockError < StandardError
303
+ end
304
+
305
+ class EvaledJob
306
+ def initialize
307
+ @job = yield
308
+ end
309
+
310
+ def perform
311
+ eval(@job)
312
+ end
313
+ 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
+ DM_STRING_FORMAT = /^DM\:([A-Z][\w\:]+)\:(.*)$/
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 DM_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 DataMapper::ObjectNotFoundError
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 DM_STRING_FORMAT then $1.constantize.get!($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 DataMapper::Resource then dm_to_string(arg)
43
+ else arg
44
+ end
45
+ end
46
+
47
+ def dm_to_string(obj)
48
+ "DM:#{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.inject(0) { |sum, i| sum+= i }
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,11 @@
1
+ require File.dirname(__FILE__) + '/dm-delayed-job/message_sending'
2
+ require File.dirname(__FILE__) + '/dm-delayed-job/performable_method'
3
+ require File.dirname(__FILE__) + '/dm-delayed-job/job'
4
+ require File.dirname(__FILE__) + '/dm-delayed-job/worker'
5
+
6
+ Object.send(:include, Delayed::MessageSending)
7
+ Module.send(:include, Delayed::MessageSending::ClassMethods)
8
+
9
+ if defined?(Merb::Plugins)
10
+ Merb::Plugins.add_rakefiles File.dirname(__FILE__) / '..' / 'tasks' / 'tasks'
11
+ end
data/spec/database.rb ADDED
@@ -0,0 +1,28 @@
1
+ $:.unshift(File.dirname(__FILE__) + '/../lib')
2
+ $:.unshift(File.dirname(__FILE__) + '/../../rspec/lib')
3
+
4
+ require 'rubygems'
5
+ require 'spec'
6
+
7
+ puts "Running tests with DataMapper as the ORM."
8
+ require 'active_support'
9
+ require 'dm-core'
10
+ require File.dirname(__FILE__) + '/../init'
11
+ DataMapper.logger = Logger.new('/tmp/dj.log')
12
+ DataMapper.setup(:default, 'sqlite3::memory:')
13
+ class Story
14
+ include DataMapper::Resource
15
+ property :id, Serial
16
+ property :text, Text
17
+
18
+ def tell; text; end
19
+ def whatever(n, _); tell*n; end
20
+
21
+ handle_asynchronously :whatever
22
+ end
23
+
24
+ class SomethingWithStringId
25
+ include DataMapper::Resource
26
+ property :id, String, :key => true
27
+ end
28
+ DataMapper.auto_migrate!
@@ -0,0 +1,136 @@
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
+ error = DataMapper::ObjectNotFoundError
18
+ raise error, '...'
19
+ false
20
+ end
21
+
22
+ end
23
+
24
+ class StoryReader
25
+
26
+ def read(story)
27
+ "Epilog: #{story.tell}"
28
+ end
29
+
30
+ end
31
+
32
+ class StoryReader
33
+
34
+ def read(story)
35
+ "Epilog: #{story.tell}"
36
+ end
37
+
38
+ end
39
+
40
+ describe 'random ruby objects' do
41
+ before { Delayed::Job.delete_all }
42
+
43
+ it "should respond_to :send_later method" do
44
+
45
+ RandomRubyObject.new.respond_to?(:send_later)
46
+
47
+ end
48
+
49
+ it "should raise a ArgumentError if send_later is called but the target method doesn't exist" do
50
+ lambda { RandomRubyObject.new.send_later(:method_that_deos_not_exist) }.should raise_error(NoMethodError)
51
+ end
52
+
53
+ it "should add a new entry to the job table when send_later is called on it" do
54
+ Delayed::Job.count.should == 0
55
+
56
+ RandomRubyObject.new.send_later(:to_s)
57
+
58
+ Delayed::Job.count.should == 1
59
+ end
60
+
61
+ it "should add a new entry to the job table when send_later is called on the class" do
62
+ Delayed::Job.count.should == 0
63
+
64
+ RandomRubyObject.send_later(:to_s)
65
+
66
+ Delayed::Job.count.should == 1
67
+ end
68
+
69
+ it "should run get the original method executed when the job is performed" do
70
+
71
+ RandomRubyObject.new.send_later(:say_hello)
72
+
73
+ Delayed::Job.count.should == 1
74
+ end
75
+
76
+ it "should ignore ActiveRecord::RecordNotFound errors because they are permanent" do
77
+
78
+ ErrorObject.new.send_later(:throw)
79
+
80
+ Delayed::Job.count.should == 1
81
+
82
+ Delayed::Job.reserve_and_run_one_job
83
+
84
+ Delayed::Job.count.should == 0
85
+
86
+ end
87
+
88
+ it "should store the object as string if its a DataMapper" do
89
+ story = Story.create :text => 'Once upon...'
90
+ story.send_later(:tell)
91
+
92
+ job = Delayed::Job.first
93
+ job.payload_object.class.should == Delayed::PerformableMethod
94
+ job.payload_object.object.should == "DM:Story:#{story.id}"
95
+ job.payload_object.method.should == :tell
96
+ job.payload_object.args.should == []
97
+ job.payload_object.perform.should == 'Once upon...'
98
+ end
99
+
100
+ it "should store arguments as string if they a DataMapper" do
101
+
102
+ story = Story.create :text => 'Once upon...'
103
+
104
+ reader = StoryReader.new
105
+ reader.send_later(:read, story)
106
+
107
+ job = Delayed::Job.first
108
+ job.payload_object.class.should == Delayed::PerformableMethod
109
+ job.payload_object.method.should == :read
110
+ job.payload_object.args.should == ["DM:Story:#{story.id}"]
111
+ job.payload_object.perform.should == 'Epilog: Once upon...'
112
+ end
113
+
114
+ it "should store DataMapper object as string even if it's id is some string" do
115
+ SomethingWithStringId.create(:id => "something stringy").send_later(:class)
116
+
117
+ job = Delayed::Job.first
118
+ job.payload_object.perform.should eql(SomethingWithStringId)
119
+ end
120
+
121
+ it "should call send later on methods which are wrapped with handle_asynchronously" do
122
+ story = Story.create :text => 'Once upon...'
123
+
124
+ Delayed::Job.count.should == 0
125
+
126
+ story.whatever(1, 5)
127
+
128
+ Delayed::Job.count.should == 1
129
+ job = Delayed::Job.first
130
+ job.payload_object.class.should == Delayed::PerformableMethod
131
+ job.payload_object.method.should == :whatever_without_send_later
132
+ job.payload_object.args.should == [1, 5]
133
+ job.payload_object.perform.should == 'Once upon...'
134
+ end
135
+
136
+ end
data/spec/job_spec.rb ADDED
@@ -0,0 +1,344 @@
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 = (Delayed::Job.db_time_now+5.minutes)
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.to_time.should be_close(later.to_time, 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.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 => Delayed::Job.db_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
+ @job.lock_exclusively!(4.hours, 'worker2').should == false
188
+ end
189
+
190
+ it "should allow a second worker to get exclusive access if the timeout has passed" do
191
+ @job.lock_exclusively!(1.minute, 'worker2').should == true
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
+ @job.lock_exclusively! 4.hours, 'worker2'
198
+ @job.reload
199
+ @job.locked_by.should == 'worker2'
200
+ @job.locked_at.should > 1.minute.ago
201
+ end
202
+
203
+ it "should not be found by another worker" do
204
+ Delayed::Job.worker_name = 'worker2'
205
+
206
+ Delayed::Job.find_available(1, 6.minutes).length.should == 0
207
+ end
208
+
209
+ it "should be found by another worker if the time has expired" do
210
+ Delayed::Job.worker_name = 'worker2'
211
+
212
+ Delayed::Job.find_available(1, 4.minutes).length.should == 1
213
+ end
214
+
215
+ it "should be able to get exclusive access again when the worker name is the same" do
216
+ @job.lock_exclusively! 5.minutes, 'worker1'
217
+ @job.lock_exclusively! 5.minutes, 'worker1'
218
+ @job.lock_exclusively! 5.minutes, 'worker1'
219
+ end
220
+ end
221
+
222
+ context "#name" do
223
+ it "should be the class name of the job that was enqueued" do
224
+ Delayed::Job.create(:payload_object => ErrorJob.new ).name.should == 'ErrorJob'
225
+ end
226
+
227
+ it "should be the method that will be called if its a performable method object" do
228
+ Delayed::Job.send_later(:clear_locks!)
229
+ Delayed::Job.last.name.should == 'Delayed::Job.clear_locks!'
230
+
231
+ end
232
+ it "should be the instance method that will be called if its a performable method object" do
233
+ story = Story.create :text => "..."
234
+
235
+ story.send_later(:save)
236
+
237
+ Delayed::Job.last.name.should == 'Story#save'
238
+ end
239
+ end
240
+
241
+ context "worker prioritization" do
242
+
243
+ before(:each) do
244
+ Delayed::Job.max_priority = nil
245
+ Delayed::Job.min_priority = nil
246
+ end
247
+
248
+ it "should only work_off jobs that are >= min_priority" do
249
+ Delayed::Job.min_priority = -5
250
+ Delayed::Job.max_priority = 5
251
+ SimpleJob.runs.should == 0
252
+
253
+ Delayed::Job.enqueue SimpleJob.new, -10
254
+ Delayed::Job.enqueue SimpleJob.new, 0
255
+ Delayed::Job.work_off
256
+
257
+ SimpleJob.runs.should == 1
258
+ end
259
+
260
+ it "should only work_off jobs that are <= max_priority" do
261
+ Delayed::Job.min_priority = -5
262
+ Delayed::Job.max_priority = 5
263
+ SimpleJob.runs.should == 0
264
+
265
+ Delayed::Job.enqueue SimpleJob.new, 10
266
+ Delayed::Job.enqueue SimpleJob.new, 0
267
+
268
+ Delayed::Job.work_off
269
+
270
+ SimpleJob.runs.should == 1
271
+ end
272
+
273
+ end
274
+
275
+ context "when pulling jobs off the queue for processing, it" do
276
+ before(:each) do
277
+ @job = Delayed::Job.create(
278
+ :payload_object => SimpleJob.new,
279
+ :locked_by => 'worker1',
280
+ :locked_at => Delayed::Job.db_time_now - 5.minutes)
281
+ end
282
+
283
+ it "should leave the queue in a consistent state and not run the job if locking fails" do
284
+ SimpleJob.runs.should == 0
285
+ @job.stub!(:lock_exclusively!).with(any_args).once.and_return(false)
286
+ Delayed::Job.should_receive(:find_available).once.and_return([@job])
287
+ Delayed::Job.work_off(1)
288
+ SimpleJob.runs.should == 0
289
+ end
290
+
291
+ end
292
+
293
+ context "while running alongside other workers that locked jobs, it" do
294
+ before(:each) do
295
+ Delayed::Job.worker_name = 'worker1'
296
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 1.minutes))
297
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker2', :locked_at => (Delayed::Job.db_time_now - 1.minutes))
298
+ Delayed::Job.create(:payload_object => SimpleJob.new)
299
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 1.minutes))
300
+ end
301
+
302
+ it "should ingore locked jobs from other workers" do
303
+ Delayed::Job.worker_name = 'worker3'
304
+ SimpleJob.runs.should == 0
305
+ Delayed::Job.work_off
306
+ SimpleJob.runs.should == 1 # runs the one open job
307
+ end
308
+
309
+ it "should find our own jobs regardless of locks" do
310
+ Delayed::Job.worker_name = 'worker1'
311
+ SimpleJob.runs.should == 0
312
+ Delayed::Job.work_off
313
+ SimpleJob.runs.should == 3 # runs open job plus worker1 jobs that were already locked
314
+ end
315
+ end
316
+
317
+ context "while running with locked and expired jobs, it" do
318
+ before(:each) do
319
+ Delayed::Job.worker_name = 'worker1'
320
+ exp_time = Delayed::Job.db_time_now - (1.minutes + Delayed::Job::MAX_RUN_TIME)
321
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => exp_time)
322
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker2', :locked_at => (Delayed::Job.db_time_now - 1.minutes))
323
+ Delayed::Job.create(:payload_object => SimpleJob.new)
324
+ Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 1.minutes))
325
+ end
326
+
327
+ it "should only find unlocked and expired jobs" do
328
+ Delayed::Job.worker_name = 'worker3'
329
+ SimpleJob.runs.should == 0
330
+ Delayed::Job.work_off
331
+ SimpleJob.runs.should == 2 # runs the one open job and one expired job
332
+ end
333
+
334
+ it "should ignore locks when finding our own jobs" do
335
+ Delayed::Job.worker_name = 'worker1'
336
+ SimpleJob.runs.should == 0
337
+ Delayed::Job.work_off
338
+ SimpleJob.runs.should == 3 # runs open job plus worker1 jobs
339
+ # This is useful in the case of a crash/restart on worker1, but make sure multiple workers on the same host have unique names!
340
+ end
341
+
342
+ end
343
+
344
+ 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,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dm-delayed-job
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 1
8
+ - 0
9
+ version: 0.1.0
10
+ platform: ruby
11
+ authors:
12
+ - "Tobias L\xC3\xBCtke"
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2010-02-22 00:00:00 +02:00
18
+ default_executable:
19
+ dependencies: []
20
+
21
+ description: Delated_job (or DJ) encapsulates the common pattern of asynchronously executing longer tasks in the background. It is port to DataMapper of direct extraction from Shopify where the job table is responsible for a multitude of core tasks.
22
+ email: tobi@leetsoft.com
23
+ executables: []
24
+
25
+ extensions: []
26
+
27
+ extra_rdoc_files:
28
+ - README.textile
29
+ files:
30
+ - .gitignore
31
+ - MIT-LICENSE
32
+ - README.textile
33
+ - Rakefile
34
+ - VERSION
35
+ - init.rb
36
+ - lib/dm-delayed-job.rb
37
+ - lib/dm-delayed-job/job.rb
38
+ - lib/dm-delayed-job/message_sending.rb
39
+ - lib/dm-delayed-job/performable_method.rb
40
+ - lib/dm-delayed-job/worker.rb
41
+ - spec/database.rb
42
+ - spec/delayed_method_spec.rb
43
+ - spec/job_spec.rb
44
+ - spec/story_spec.rb
45
+ - tasks/jobs.rake
46
+ - tasks/tasks.rb
47
+ has_rdoc: true
48
+ homepage: http://github.com/windock/delayed_job
49
+ licenses: []
50
+
51
+ post_install_message:
52
+ rdoc_options:
53
+ - --charset=UTF-8
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ segments:
61
+ - 0
62
+ version: "0"
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ segments:
68
+ - 0
69
+ version: "0"
70
+ requirements: []
71
+
72
+ rubyforge_project:
73
+ rubygems_version: 1.3.6
74
+ signing_key:
75
+ specification_version: 3
76
+ summary: Database-backed asynchronous priority queue system -- Extracted from Shopify
77
+ test_files:
78
+ - spec/database.rb
79
+ - spec/delayed_method_spec.rb
80
+ - spec/job_spec.rb
81
+ - spec/story_spec.rb