jmcnevin-delayed_job 2.0.4

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