jeffkreeftmeijer-delayed_job 0.1.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/.gitignore +1 -0
- data/MIT-LICENSE +20 -0
- data/README.textile +110 -0
- data/Rakefile +35 -0
- data/VERSION +1 -0
- data/delayed_job.gemspec +41 -0
- data/init.rb +1 -0
- data/jkreeftmeijer-delayed_job.gemspec +64 -0
- data/lib/delayed/job.rb +146 -0
- data/lib/delayed/job/active_record_job.rb +151 -0
- data/lib/delayed/job/mongo_job.rb +185 -0
- data/lib/delayed/message_sending.rb +17 -0
- data/lib/delayed/performable_method.rb +55 -0
- data/lib/delayed/worker.rb +54 -0
- data/lib/delayed_job.rb +25 -0
- data/spec/delayed_method_spec.rb +126 -0
- data/spec/job_spec.rb +347 -0
- data/spec/setup/active_record.rb +42 -0
- data/spec/setup/mongo.rb +22 -0
- data/spec/story_spec.rb +15 -0
- data/tasks/jobs.rake +1 -0
- data/tasks/tasks.rb +15 -0
- metadata +82 -0
@@ -0,0 +1,185 @@
|
|
1
|
+
module Delayed
|
2
|
+
|
3
|
+
# A job object that is persisted to the database.
|
4
|
+
# Contains the work object as a YAML field.
|
5
|
+
class Job
|
6
|
+
include MongoMapper::Document
|
7
|
+
|
8
|
+
key :priority, Integer, :default => 0
|
9
|
+
key :attempts, Integer, :default => 0
|
10
|
+
key :handler
|
11
|
+
key :last_error
|
12
|
+
key :run_at, Time
|
13
|
+
key :locked_at, Time
|
14
|
+
key :locked_by
|
15
|
+
key :failed_at, Time, :default => nil
|
16
|
+
timestamps!
|
17
|
+
|
18
|
+
collection_name = 'delayed_jobs'
|
19
|
+
|
20
|
+
def self.last(opts={})
|
21
|
+
super(opts.merge(:order => 'id'))
|
22
|
+
end
|
23
|
+
|
24
|
+
# When a worker is exiting, make sure we don't have any locked jobs.
|
25
|
+
def self.clear_locks!
|
26
|
+
collection.update({:locked_by => worker_name}, {"$set" => {:locked_by => nil, :locked_at => nil}}, :multi => true)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Reschedule the job in the future (when a job fails).
|
30
|
+
# Uses an exponential scale depending on the number of failed attempts.
|
31
|
+
def reschedule(message, backtrace = [], time = nil)
|
32
|
+
if self.attempts < MAX_ATTEMPTS
|
33
|
+
time ||= Job.db_time_now + (attempts ** 4) + 5
|
34
|
+
|
35
|
+
self.attempts += 1
|
36
|
+
self.run_at = time
|
37
|
+
self.last_error = message + "\n" + backtrace.join("\n")
|
38
|
+
self.unlock
|
39
|
+
save!
|
40
|
+
else
|
41
|
+
logger.info "* [JOB] PERMANENTLY removing #{self.name} because of #{attempts} consequetive failures."
|
42
|
+
destroy_failed_jobs ? destroy : update_attributes(:failed_at => Delayed::Job.db_time_now)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Try to run one job. Returns true/false (work done/work failed) or nil if job can't be locked.
|
47
|
+
def run_with_lock(max_run_time, worker_name)
|
48
|
+
logger.info "* [JOB] aquiring lock on #{name}"
|
49
|
+
unless lock_exclusively!(max_run_time, worker_name)
|
50
|
+
# We did not get the lock, some other worker process must have
|
51
|
+
logger.warn "* [JOB] failed to aquire exclusive lock for #{name}"
|
52
|
+
return nil # no work done
|
53
|
+
end
|
54
|
+
|
55
|
+
begin
|
56
|
+
runtime = Benchmark.realtime do
|
57
|
+
invoke_job # TODO: raise error if takes longer than max_run_time
|
58
|
+
logger.info "Also destroying self"
|
59
|
+
destroy
|
60
|
+
end
|
61
|
+
# TODO: warn if runtime > max_run_time ?
|
62
|
+
logger.info "* [JOB] #{name} completed after %.4f" % runtime
|
63
|
+
return true # did work
|
64
|
+
rescue Exception => e
|
65
|
+
reschedule e.message, e.backtrace
|
66
|
+
log_exception(e)
|
67
|
+
return false # work failed
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Add a job to the queue
|
72
|
+
def self.enqueue(*args, &block)
|
73
|
+
object = block_given? ? EvaledJob.new(&block) : args.shift
|
74
|
+
|
75
|
+
unless object.respond_to?(:perform) || block_given?
|
76
|
+
raise ArgumentError, 'Cannot enqueue items which do not respond to perform'
|
77
|
+
end
|
78
|
+
|
79
|
+
priority = args.first || 0
|
80
|
+
run_at = args[1]
|
81
|
+
|
82
|
+
Job.create(:payload_object => object, :priority => priority.to_i, :run_at => run_at)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Find a few candidate jobs to run (in case some immediately get locked by others).
|
86
|
+
# Return in random order prevent everyone trying to do same head job at once.
|
87
|
+
def self.find_available(limit = 5, max_run_time = MAX_RUN_TIME)
|
88
|
+
time_now = db_time_now
|
89
|
+
|
90
|
+
conditions = {}
|
91
|
+
|
92
|
+
if self.min_priority
|
93
|
+
conditions["priority"] ||= {}
|
94
|
+
conditions["priority"].merge!("$gte" => min_priority)
|
95
|
+
end
|
96
|
+
|
97
|
+
if self.max_priority
|
98
|
+
conditions["priority"] ||= {}
|
99
|
+
conditions["priority"].merge!("$lte" => max_priority)
|
100
|
+
end
|
101
|
+
|
102
|
+
overtime = make_date(time_now - max_run_time.to_i)
|
103
|
+
query = "(this.run_at <= #{make_date(time_now)} && (this.locked_at == null || this.locked_at < #{overtime}) || this.locked_by == '#{worker_name}') && this.failed_at == null"
|
104
|
+
|
105
|
+
conditions.merge!("$where" => make_query(query))
|
106
|
+
|
107
|
+
records = collection.find(conditions, {:sort => [['priority', 'descending'], ['run_at', 'ascending']], :limit => limit}).map {|x| new(x)} #, :order => NextTaskOrder, :limit => limit)
|
108
|
+
records.sort_by { rand() }
|
109
|
+
end
|
110
|
+
|
111
|
+
def self.make_date(date)
|
112
|
+
"new Date(#{date.to_f * 1000})"
|
113
|
+
end
|
114
|
+
|
115
|
+
def make_date(date)
|
116
|
+
self.class.make_date(date)
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.make_query(string)
|
120
|
+
Mongo::Code.new("function() { return (#{string}); }")
|
121
|
+
end
|
122
|
+
|
123
|
+
def make_query(string)
|
124
|
+
self.class.make_query(string)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Run the next job we can get an exclusive lock on.
|
128
|
+
# If no jobs are left we return nil
|
129
|
+
def self.reserve_and_run_one_job(max_run_time = MAX_RUN_TIME)
|
130
|
+
|
131
|
+
# We get up to 5 jobs from the db. In case we cannot get exclusive access to a job we try the next.
|
132
|
+
# this leads to a more even distribution of jobs across the worker processes
|
133
|
+
find_available(5, max_run_time).each do |job|
|
134
|
+
t = job.run_with_lock(max_run_time, worker_name)
|
135
|
+
return t unless t == nil # return if we did work (good or bad)
|
136
|
+
end
|
137
|
+
|
138
|
+
nil # we didn't do any work, all 5 were not lockable
|
139
|
+
end
|
140
|
+
|
141
|
+
# Lock this job for this worker.
|
142
|
+
# Returns true if we have the lock, false otherwise.
|
143
|
+
def lock_exclusively!(max_run_time, worker = worker_name)
|
144
|
+
now = self.class.db_time_now
|
145
|
+
|
146
|
+
affected_rows = if locked_by != worker
|
147
|
+
overtime = make_date(now - max_run_time.to_i)
|
148
|
+
query = "this._id == '#{id}' && (this.locked_at == null || this.locked_at < #{overtime})"
|
149
|
+
|
150
|
+
conditions = {"$where" => make_query(query)}
|
151
|
+
matches = collection.find(conditions).count
|
152
|
+
collection.update(conditions, {"$set" => {:locked_at => now, :locked_by => worker}}, :multi => true)
|
153
|
+
matches
|
154
|
+
else
|
155
|
+
conditions = {"_id" => Mongo::ObjectID.from_string(id), "locked_by" => worker}
|
156
|
+
matches = collection.find(conditions).count
|
157
|
+
collection.update(conditions, {"$set" => {"locked_at" => now}}, :multi => true)
|
158
|
+
matches
|
159
|
+
end
|
160
|
+
if affected_rows == 1
|
161
|
+
self.locked_at = now
|
162
|
+
self.locked_by = worker
|
163
|
+
return true
|
164
|
+
else
|
165
|
+
return false
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
private
|
170
|
+
|
171
|
+
# Get the current time (GMT or local depending on DB)
|
172
|
+
# Note: This does not ping the DB to get the time, so all your clients
|
173
|
+
# must have syncronized clocks.
|
174
|
+
def self.db_time_now
|
175
|
+
Time.now.utc
|
176
|
+
end
|
177
|
+
|
178
|
+
protected
|
179
|
+
|
180
|
+
def before_save
|
181
|
+
self.run_at ||= self.class.db_time_now
|
182
|
+
end
|
183
|
+
|
184
|
+
end
|
185
|
+
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 PERFORMABLE_METHOD_EXCEPTION
|
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 PERFORMABLE_METHOD_STORE 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
|
data/lib/delayed_job.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/delayed/message_sending'
|
2
|
+
require File.dirname(__FILE__) + '/delayed/performable_method'
|
3
|
+
|
4
|
+
if defined?(MongoMapper)
|
5
|
+
require File.dirname(__FILE__) + '/delayed/job/mongo_job'
|
6
|
+
|
7
|
+
PERFORMABLE_METHOD_EXCEPTION = Exception
|
8
|
+
PERFORMABLE_METHOD_STORE = MongoMapper::Document
|
9
|
+
else
|
10
|
+
autoload :ActiveRecord, 'activerecord'
|
11
|
+
require File.dirname(__FILE__) + '/delayed/job/active_record_job'
|
12
|
+
|
13
|
+
PERFORMABLE_METHOD_EXCEPTION = ActiveRecord::RecordNotFound
|
14
|
+
PERFORMABLE_METHOD_STORE = ActiveRecord::Base
|
15
|
+
end
|
16
|
+
|
17
|
+
require File.dirname(__FILE__) + '/delayed/job'
|
18
|
+
require File.dirname(__FILE__) + '/delayed/worker'
|
19
|
+
|
20
|
+
Object.send(:include, Delayed::MessageSending)
|
21
|
+
Module.send(:include, Delayed::MessageSending::ClassMethods)
|
22
|
+
|
23
|
+
if defined?(Merb::Plugins)
|
24
|
+
Merb::Plugins.add_rakefiles File.dirname(__FILE__) / '..' / 'tasks' / 'tasks'
|
25
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
class SimpleJob
|
2
|
+
cattr_accessor :runs; self.runs = 0
|
3
|
+
def perform; @@runs += 1; end
|
4
|
+
end
|
5
|
+
|
6
|
+
class RandomRubyObject
|
7
|
+
def say_hello
|
8
|
+
'hello'
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class ErrorObject
|
13
|
+
|
14
|
+
def throw
|
15
|
+
raise ActiveRecord::RecordNotFound, '...'
|
16
|
+
false
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
|
21
|
+
class StoryReader
|
22
|
+
|
23
|
+
def read(story)
|
24
|
+
"Epilog: #{story.tell}"
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
class StoryReader
|
30
|
+
|
31
|
+
def read(story)
|
32
|
+
"Epilog: #{story.tell}"
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
describe 'random ruby objects' do
|
38
|
+
before { Delayed::Job.delete_all }
|
39
|
+
|
40
|
+
it "should respond_to :send_later method" do
|
41
|
+
|
42
|
+
RandomRubyObject.new.respond_to?(:send_later)
|
43
|
+
|
44
|
+
end
|
45
|
+
|
46
|
+
it "should raise a ArgumentError if send_later is called but the target method doesn't exist" do
|
47
|
+
lambda { RandomRubyObject.new.send_later(:method_that_deos_not_exist) }.should raise_error(NoMethodError)
|
48
|
+
end
|
49
|
+
|
50
|
+
it "should add a new entry to the job table when send_later is called on it" do
|
51
|
+
Delayed::Job.count.should == 0
|
52
|
+
|
53
|
+
RandomRubyObject.new.send_later(:to_s)
|
54
|
+
|
55
|
+
Delayed::Job.count.should == 1
|
56
|
+
end
|
57
|
+
|
58
|
+
it "should add a new entry to the job table when send_later is called on the class" do
|
59
|
+
Delayed::Job.count.should == 0
|
60
|
+
|
61
|
+
RandomRubyObject.send_later(:to_s)
|
62
|
+
|
63
|
+
Delayed::Job.count.should == 1
|
64
|
+
end
|
65
|
+
|
66
|
+
it "should run get the original method executed when the job is performed" do
|
67
|
+
|
68
|
+
RandomRubyObject.new.send_later(:say_hello)
|
69
|
+
|
70
|
+
Delayed::Job.count.should == 1
|
71
|
+
end
|
72
|
+
|
73
|
+
it "should ignore ActiveRecord::RecordNotFound errors because they are permanent" do
|
74
|
+
|
75
|
+
ErrorObject.new.send_later(:throw)
|
76
|
+
|
77
|
+
Delayed::Job.count.should == 1
|
78
|
+
|
79
|
+
Delayed::Job.reserve_and_run_one_job
|
80
|
+
|
81
|
+
Delayed::Job.count.should == 0
|
82
|
+
|
83
|
+
end
|
84
|
+
|
85
|
+
it "should store the object as string if its an active record" do
|
86
|
+
story = Story.create :text => 'Once upon...'
|
87
|
+
story.send_later(:tell)
|
88
|
+
|
89
|
+
job = Delayed::Job.find(:first)
|
90
|
+
job.payload_object.class.should == Delayed::PerformableMethod
|
91
|
+
job.payload_object.object.should == "AR:Story:#{story.id}"
|
92
|
+
job.payload_object.method.should == :tell
|
93
|
+
job.payload_object.args.should == []
|
94
|
+
job.payload_object.perform.should == 'Once upon...'
|
95
|
+
end
|
96
|
+
|
97
|
+
it "should store arguments as string if they an active record" do
|
98
|
+
|
99
|
+
story = Story.create :text => 'Once upon...'
|
100
|
+
|
101
|
+
reader = StoryReader.new
|
102
|
+
reader.send_later(:read, story)
|
103
|
+
|
104
|
+
job = Delayed::Job.find(:first)
|
105
|
+
job.payload_object.class.should == Delayed::PerformableMethod
|
106
|
+
job.payload_object.method.should == :read
|
107
|
+
job.payload_object.args.should == ["AR:Story:#{story.id}"]
|
108
|
+
job.payload_object.perform.should == 'Epilog: Once upon...'
|
109
|
+
end
|
110
|
+
|
111
|
+
it "should call send later on methods which are wrapped with handle_asynchronously" do
|
112
|
+
story = Story.create :text => 'Once upon...'
|
113
|
+
|
114
|
+
Delayed::Job.count.should == 0
|
115
|
+
|
116
|
+
story.whatever(1, 5)
|
117
|
+
|
118
|
+
Delayed::Job.count.should == 1
|
119
|
+
job = Delayed::Job.find(:first)
|
120
|
+
job.payload_object.class.should == Delayed::PerformableMethod
|
121
|
+
job.payload_object.method.should == :whatever_without_send_later
|
122
|
+
job.payload_object.args.should == [1, 5]
|
123
|
+
job.payload_object.perform.should == 'Once upon...'
|
124
|
+
end
|
125
|
+
|
126
|
+
end
|