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.
@@ -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