jmcnevin-delayed_job 2.0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. data/.gitignore +3 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.textile +213 -0
  4. data/Rakefile +45 -0
  5. data/VERSION +1 -0
  6. data/benchmarks.rb +33 -0
  7. data/contrib/delayed_job.monitrc +14 -0
  8. data/contrib/delayed_job_multiple.monitrc +23 -0
  9. data/delayed_job.gemspec +107 -0
  10. data/generators/delayed_job/delayed_job_generator.rb +21 -0
  11. data/generators/delayed_job/templates/migration.rb +21 -0
  12. data/init.rb +1 -0
  13. data/lib/delayed/backend/active_record.rb +90 -0
  14. data/lib/delayed/backend/base.rb +111 -0
  15. data/lib/delayed/backend/data_mapper.rb +125 -0
  16. data/lib/delayed/backend/mongo_mapper.rb +110 -0
  17. data/lib/delayed/message_sending.rb +22 -0
  18. data/lib/delayed/performable_method.rb +62 -0
  19. data/lib/delayed/railtie.rb +10 -0
  20. data/lib/delayed/recipes.rb +31 -0
  21. data/lib/delayed/tasks.rb +15 -0
  22. data/lib/delayed/worker.rb +199 -0
  23. data/lib/delayed_job.rb +14 -0
  24. data/rails/init.rb +5 -0
  25. data/recipes/delayed_job.rb +1 -0
  26. data/spec/backend/active_record_job_spec.rb +46 -0
  27. data/spec/backend/data_mapper_job_spec.rb +16 -0
  28. data/spec/backend/mongo_mapper_job_spec.rb +94 -0
  29. data/spec/backend/shared_backend_spec.rb +265 -0
  30. data/spec/delayed_method_spec.rb +59 -0
  31. data/spec/performable_method_spec.rb +42 -0
  32. data/spec/sample_jobs.rb +25 -0
  33. data/spec/setup/active_record.rb +33 -0
  34. data/spec/setup/data_mapper.rb +8 -0
  35. data/spec/setup/mongo_mapper.rb +17 -0
  36. data/spec/spec_helper.rb +26 -0
  37. data/spec/story_spec.rb +17 -0
  38. data/spec/worker_spec.rb +216 -0
  39. data/tasks/jobs.rake +1 -0
  40. metadata +241 -0
@@ -0,0 +1,22 @@
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
+ def send_at(time, method, *args)
8
+ Delayed::Job.enqueue(Delayed::PerformableMethod.new(self, method.to_sym, args), 0, time)
9
+ end
10
+
11
+ module ClassMethods
12
+ def handle_asynchronously(method)
13
+ aliased_method, punctuation = method.to_s.sub(/([?!=])$/, ''), $1
14
+ with_method, without_method = "#{aliased_method}_with_send_later#{punctuation}", "#{aliased_method}_without_send_later#{punctuation}"
15
+ define_method(with_method) do |*args|
16
+ send_later(without_method, *args)
17
+ end
18
+ alias_method_chain method, :send_later
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,62 @@
1
+ class Class
2
+ def load_for_delayed_job(arg)
3
+ self
4
+ end
5
+
6
+ def dump_for_delayed_job
7
+ name
8
+ end
9
+ end
10
+
11
+ module Delayed
12
+ class PerformableMethod < Struct.new(:object, :method, :args)
13
+ STRING_FORMAT = /^LOAD\;([A-Z][\w\:]+)(?:\;(\w+))?$/
14
+
15
+ class LoadError < StandardError
16
+ end
17
+
18
+ def initialize(object, method, args)
19
+ raise NoMethodError, "undefined method `#{method}' for #{object.inspect}" unless object.respond_to?(method)
20
+
21
+ self.object = dump(object)
22
+ self.args = args.map { |a| dump(a) }
23
+ self.method = method.to_sym
24
+ end
25
+
26
+ def display_name
27
+ if STRING_FORMAT === object
28
+ "#{$1}#{$2 ? '#' : '.'}#{method}"
29
+ else
30
+ "#{object.class}##{method}"
31
+ end
32
+ end
33
+
34
+ def perform
35
+ load(object).send(method, *args.map{|a| load(a)})
36
+ rescue PerformableMethod::LoadError
37
+ # We cannot do anything about objects that can't be loaded
38
+ true
39
+ end
40
+
41
+ private
42
+
43
+ def load(obj)
44
+ if STRING_FORMAT === obj
45
+ $1.constantize.load_for_delayed_job($2)
46
+ else
47
+ obj
48
+ end
49
+ rescue => e
50
+ Delayed::Worker.logger.warn "Could not load object for job: #{e.message}"
51
+ raise PerformableMethod::LoadError
52
+ end
53
+
54
+ def dump(obj)
55
+ if obj.respond_to?(:dump_for_delayed_job)
56
+ "LOAD;#{obj.dump_for_delayed_job}"
57
+ else
58
+ obj
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,10 @@
1
+ require 'delayed_job'
2
+ require 'rails'
3
+
4
+ module Delayed
5
+ class Railtie < Rails::Railtie
6
+ initializer :after_initialize do
7
+ Delayed::Worker.guess_backend
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,31 @@
1
+ # Capistrano Recipes for managing delayed_job
2
+ #
3
+ # Add these callbacks to have the delayed_job process restart when the server
4
+ # is restarted:
5
+ #
6
+ # after "deploy:stop", "delayed_job:stop"
7
+ # after "deploy:start", "delayed_job:start"
8
+ # after "deploy:restart", "delayed_job:restart"
9
+
10
+ Capistrano::Configuration.instance.load do
11
+ namespace :delayed_job do
12
+ def rails_env
13
+ fetch(:rails_env, false) ? "RAILS_ENV=#{fetch(:rails_env)}" : ''
14
+ end
15
+
16
+ desc "Stop the delayed_job process"
17
+ task :stop, :roles => :app do
18
+ run "cd #{current_path};#{rails_env} script/delayed_job stop"
19
+ end
20
+
21
+ desc "Start the delayed_job process"
22
+ task :start, :roles => :app do
23
+ run "cd #{current_path};#{rails_env} script/delayed_job start"
24
+ end
25
+
26
+ desc "Restart the delayed_job process"
27
+ task :restart, :roles => :app do
28
+ run "cd #{current_path};#{rails_env} script/delayed_job restart"
29
+ end
30
+ end
31
+ end
@@ -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
@@ -0,0 +1,199 @@
1
+ require 'timeout'
2
+ require 'active_support/core_ext/numeric/time'
3
+
4
+ module Delayed
5
+ class Worker
6
+ cattr_accessor :min_priority,
7
+ :max_priority,
8
+ :max_attempts,
9
+ :max_run_time,
10
+ :default_priority,
11
+ :sleep_delay,
12
+ :logger,
13
+ :trap_signals,
14
+ :destroy_failed_jobs
15
+
16
+ cattr_reader :backend
17
+
18
+ # name_prefix is ignored if name is set directly
19
+ attr_accessor :name_prefix
20
+
21
+ # default worker settings
22
+ self.sleep_delay = 5
23
+ self.max_attempts = 25
24
+ self.max_run_time = 4.hours
25
+ self.default_priority = 0
26
+ self.trap_signals = true
27
+ self.destroy_failed_jobs = true
28
+
29
+ self.logger = if defined?(Merb::Logger)
30
+ Merb.logger
31
+ elsif defined?(RAILS_DEFAULT_LOGGER)
32
+ RAILS_DEFAULT_LOGGER
33
+ end
34
+
35
+ def self.backend=(backend)
36
+ if backend.is_a? Symbol
37
+ require "delayed/backend/#{backend}"
38
+ backend = "Delayed::Backend::#{backend.to_s.classify}::Job".constantize
39
+ end
40
+ @@backend = backend
41
+ silence_warnings { ::Delayed.const_set(:Job, backend) }
42
+ end
43
+
44
+ def self.guess_backend
45
+ self.backend ||= if defined?(ActiveRecord)
46
+ :active_record
47
+ elsif defined?(MongoMapper)
48
+ :mongo_mapper
49
+ else
50
+ logger.warn "Could not decide on a backend, defaulting to active_record"
51
+ :active_record
52
+ end
53
+ end
54
+
55
+ def initialize(options={})
56
+ @quiet = !!options.delete[:quiet]
57
+
58
+ options.each do |k,v|
59
+ method_name = :"#{k}="
60
+ if self.class.respond_to?(method_name)
61
+ self.class.send(method_name, v)
62
+ end
63
+ end
64
+ end
65
+
66
+ # Every worker has a unique name which by default is the pid of the process. There are some
67
+ # advantages to overriding this with something which survives worker retarts: Workers can#
68
+ # safely resume working on tasks which are locked by themselves. The worker will assume that
69
+ # it crashed before.
70
+ def name
71
+ return @name unless @name.nil?
72
+ "#{@name_prefix}host:#{Socket.gethostname} pid:#{Process.pid}" rescue "#{@name_prefix}pid:#{Process.pid}"
73
+ end
74
+
75
+ # Sets the name of the worker.
76
+ # Setting the name to nil will reset the default worker name
77
+ def name=(val)
78
+ @name = val
79
+ end
80
+
81
+ def start
82
+ say "*** Starting job worker #{name}"
83
+
84
+ if self.trap_signals
85
+ trap('TERM') { say 'Exiting...'; $exit = true }
86
+ trap('INT') { say 'Exiting...'; $exit = true }
87
+ end
88
+
89
+ loop do
90
+ result = nil
91
+
92
+ realtime = Benchmark.realtime do
93
+ result = work_off
94
+ end
95
+
96
+ count = result.sum
97
+
98
+ break if $exit
99
+
100
+ if count.zero?
101
+ sleep(@@sleep_delay)
102
+ else
103
+ say "#{count} jobs processed at %.4f j/s, %d failed ..." % [count / realtime, result.last]
104
+ end
105
+
106
+ break if $exit
107
+ end
108
+
109
+ ensure
110
+ Delayed::Job.clear_locks!(name)
111
+ end
112
+
113
+ # Do num jobs and return stats on success/failure.
114
+ # Exit early if interrupted.
115
+ def work_off(num = 100)
116
+ success, failure = 0, 0
117
+
118
+ num.times do
119
+ case reserve_and_run_one_job
120
+ when true
121
+ success += 1
122
+ when false
123
+ failure += 1
124
+ else
125
+ break # leave if no work could be done
126
+ end
127
+ break if $exit # leave if we're exiting
128
+ end
129
+
130
+ return [success, failure]
131
+ end
132
+
133
+ def run(job)
134
+ runtime = Benchmark.realtime do
135
+ Timeout.timeout(self.class.max_run_time.to_i) { job.invoke_job }
136
+ job.destroy
137
+ end
138
+ # TODO: warn if runtime > max_run_time ?
139
+ say "* [JOB] #{name} completed after %.4f" % runtime
140
+ return true # did work
141
+ # rescue Exception => e # rescuing Exception can have some strange downsides in my experience
142
+ rescue => e
143
+ handle_failed_job(job, e)
144
+ return false # work failed
145
+ end
146
+
147
+ # Reschedule the job in the future (when a job fails).
148
+ # Uses an exponential scale depending on the number of failed attempts.
149
+ def reschedule(job, time = nil)
150
+ if (job.attempts += 1) < self.class.max_attempts
151
+ time ||= Job.db_time_now + (job.attempts ** 4) + 5
152
+ job.run_at = time
153
+ job.unlock
154
+ job.save!
155
+ else
156
+ say "* [JOB] PERMANENTLY removing #{job.name} because of #{job.attempts} consecutive failures.", Logger::INFO
157
+
158
+ if job.payload_object.respond_to? :on_permanent_failure
159
+ say "* [JOB] Running on_permanent_failure hook"
160
+ job.payload_object.on_permanent_failure
161
+ end
162
+
163
+ self.class.destroy_failed_jobs ? job.destroy : job.update_attributes(:failed_at => Delayed::Job.db_time_now)
164
+ end
165
+ end
166
+
167
+ def say(text, level = Logger::INFO)
168
+ puts text unless @quiet
169
+ logger.add level, "#{Time.now.strftime('%FT%T%z')}: #{text}" if logger
170
+ end
171
+
172
+ protected
173
+
174
+ def handle_failed_job(job, error)
175
+ job.last_error = error.message + "\n" + error.backtrace.join("\n")
176
+ say "* [JOB] #{name} failed with #{error.class.name}: #{error.message} - #{job.attempts} failed attempts", Logger::ERROR
177
+ reschedule(job)
178
+ end
179
+
180
+ # Run the next job we can get an exclusive lock on.
181
+ # If no jobs are left we return nil
182
+ def reserve_and_run_one_job
183
+
184
+ # We get up to 5 jobs from the db. In case we cannot get exclusive access to a job we try the next.
185
+ # this leads to a more even distribution of jobs across the worker processes
186
+ job = Delayed::Job.find_available(name, 5, self.class.max_run_time).detect do |job|
187
+ if job.lock_exclusively!(self.class.max_run_time, name)
188
+ say "* [Worker(#{name})] acquired lock on #{job.name}"
189
+ true
190
+ else
191
+ say "* [Worker(#{name})] failed to acquire exclusive lock for #{job.name}", Logger::WARN
192
+ false
193
+ end
194
+ end
195
+
196
+ run(job) if job
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,14 @@
1
+ require 'active_support'
2
+
3
+ require File.dirname(__FILE__) + '/delayed/message_sending'
4
+ require File.dirname(__FILE__) + '/delayed/performable_method'
5
+ require File.dirname(__FILE__) + '/delayed/backend/base'
6
+ require File.dirname(__FILE__) + '/delayed/worker'
7
+ require File.dirname(__FILE__) + '/delayed/railtie' if defined?(::Rails::Railtie)
8
+
9
+ Object.send(:include, Delayed::MessageSending)
10
+ Module.send(:include, Delayed::MessageSending::ClassMethods)
11
+
12
+ if defined?(Merb::Plugins)
13
+ Merb::Plugins.add_rakefiles File.dirname(__FILE__) / 'delayed' / 'tasks'
14
+ end
data/rails/init.rb ADDED
@@ -0,0 +1,5 @@
1
+ require 'delayed_job'
2
+
3
+ config.after_initialize do
4
+ Delayed::Worker.guess_backend
5
+ end
@@ -0,0 +1 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib', 'delayed', 'recipes'))
@@ -0,0 +1,46 @@
1
+ require 'spec_helper'
2
+ require 'backend/shared_backend_spec'
3
+ require 'delayed/backend/active_record'
4
+
5
+ describe Delayed::Backend::ActiveRecord::Job do
6
+ before(:all) do
7
+ @backend = Delayed::Backend::ActiveRecord::Job
8
+ end
9
+
10
+ before(:each) do
11
+ Delayed::Backend::ActiveRecord::Job.delete_all
12
+ SimpleJob.runs = 0
13
+ end
14
+
15
+ after do
16
+ Time.zone = nil
17
+ end
18
+
19
+ it_should_behave_like 'a backend'
20
+
21
+ context "db_time_now" do
22
+ it "should return time in current time zone if set" do
23
+ Time.zone = 'Eastern Time (US & Canada)'
24
+ %w(EST EDT).should include(Delayed::Job.db_time_now.zone)
25
+ end
26
+
27
+ it "should return UTC time if that is the AR default" do
28
+ Time.zone = nil
29
+ ActiveRecord::Base.default_timezone = :utc
30
+ Delayed::Backend::ActiveRecord::Job.db_time_now.zone.should == 'UTC'
31
+ end
32
+
33
+ it "should return local time if that is the AR default" do
34
+ Time.zone = 'Central Time (US & Canada)'
35
+ ActiveRecord::Base.default_timezone = :local
36
+ %w(CST CDT).should include(Delayed::Backend::ActiveRecord::Job.db_time_now.zone)
37
+ end
38
+ end
39
+
40
+ describe "after_fork" do
41
+ it "should call reconnect on the connection" do
42
+ ActiveRecord::Base.connection.should_receive(:reconnect!)
43
+ Delayed::Backend::ActiveRecord::Job.after_fork
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,16 @@
1
+ require 'spec_helper'
2
+ require 'backend/shared_backend_spec'
3
+ require 'delayed/backend/data_mapper'
4
+
5
+ describe Delayed::Backend::DataMapper::Job do
6
+ before(:all) do
7
+ @backend = Delayed::Backend::DataMapper::Job
8
+ end
9
+
10
+ before(:each) do
11
+ # reset database before each example is run
12
+ DataMapper.auto_migrate!
13
+ end
14
+
15
+ it_should_behave_like 'a backend'
16
+ end
@@ -0,0 +1,94 @@
1
+ require 'spec_helper'
2
+ require 'backend/shared_backend_spec'
3
+ require 'delayed/backend/mongo_mapper'
4
+
5
+ describe Delayed::Backend::MongoMapper::Job do
6
+ before(:all) do
7
+ @backend = Delayed::Backend::MongoMapper::Job
8
+ end
9
+
10
+ before(:each) do
11
+ MongoMapper.database.collections.each(&:remove)
12
+ end
13
+
14
+ it_should_behave_like 'a backend'
15
+
16
+ describe "indexes" do
17
+ it "should have combo index on priority and run_at" do
18
+ @backend.collection.index_information.detect { |index| index[0] == 'priority_1_run_at_1' }.should_not be_nil
19
+ end
20
+
21
+ it "should have index on locked_by" do
22
+ @backend.collection.index_information.detect { |index| index[0] == 'locked_by_1' }.should_not be_nil
23
+ end
24
+ end
25
+
26
+ describe "delayed method" do
27
+ class MongoStoryReader
28
+ def read(story)
29
+ "Epilog: #{story.tell}"
30
+ end
31
+ end
32
+
33
+ class MongoStory
34
+ include ::MongoMapper::Document
35
+ key :text, String
36
+
37
+ def tell
38
+ text
39
+ end
40
+ end
41
+
42
+ it "should ignore not found errors because they are permanent" do
43
+ story = MongoStory.create :text => 'Once upon a time...'
44
+ job = story.send_later(:tell)
45
+ story.destroy
46
+ lambda { job.invoke_job }.should_not raise_error
47
+ end
48
+
49
+ it "should store the object as string" do
50
+ story = MongoStory.create :text => 'Once upon a time...'
51
+ job = story.send_later(:tell)
52
+
53
+ job.payload_object.class.should == Delayed::PerformableMethod
54
+ job.payload_object.object.should == "LOAD;MongoStory;#{story.id}"
55
+ job.payload_object.method.should == :tell
56
+ job.payload_object.args.should == []
57
+ job.payload_object.perform.should == 'Once upon a time...'
58
+ end
59
+
60
+ it "should store arguments as string" do
61
+ story = MongoStory.create :text => 'Once upon a time...'
62
+ job = MongoStoryReader.new.send_later(:read, story)
63
+ job.payload_object.class.should == Delayed::PerformableMethod
64
+ job.payload_object.method.should == :read
65
+ job.payload_object.args.should == ["LOAD;MongoStory;#{story.id}"]
66
+ job.payload_object.perform.should == 'Epilog: Once upon a time...'
67
+ end
68
+ end
69
+
70
+ describe "before_fork" do
71
+ after do
72
+ MongoMapper.connection.connect_to_master
73
+ end
74
+
75
+ it "should disconnect" do
76
+ lambda do
77
+ Delayed::Backend::MongoMapper::Job.before_fork
78
+ end.should change { !!MongoMapper.connection.connected? }.from(true).to(false)
79
+ end
80
+ end
81
+
82
+ describe "after_fork" do
83
+ before do
84
+ MongoMapper.connection.close
85
+ end
86
+
87
+ it "should call reconnect" do
88
+ lambda do
89
+ Delayed::Backend::MongoMapper::Job.after_fork
90
+ end.should change { !!MongoMapper.connection.connected? }.from(false).to(true)
91
+ end
92
+ end
93
+
94
+ end