jkreeftmeijer-delayed_job 0.1.0

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