jeffkreeftmeijer-delayed_job 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|