require File.dirname(__FILE__) + '/database' class SimpleJob cattr_accessor :runs; self.runs = 0 def perform; @@runs += 1; end end class CustomJob < SimpleJob def max_attempts; 3; end def display_name "custom name for the job" end end class ErrorJob cattr_accessor :runs; self.runs = 0 def perform; raise 'did not work'; end end class LongRunningJob def perform; sleep 250; end end module M class ModuleJob cattr_accessor :runs; self.runs = 0 def perform; @@runs += 1; end end end describe Delayed::Job do before do Delayed::Job.delete_all end before(:each) do SimpleJob.runs = 0 end it "should set run_at automatically if not set" do Delayed::Job.create(:payload_object => ErrorJob.new ).run_at.should_not == nil end it "should not set run_at automatically if already set" do later = 5.minutes.from_now Delayed::Job.create(:payload_object => ErrorJob.new, :run_at => later).run_at.should == later end it "should raise ArgumentError when handler doesn't respond_to :perform" do lambda { Delayed::Job.enqueue(Object.new) }.should raise_error(ArgumentError) end it "should increase count after enqueuing items" do Delayed::Job.enqueue SimpleJob.new Delayed::Job.count.should == 1 end it "should be able to set priority when enqueuing items" do Delayed::Job.enqueue SimpleJob.new, 5 Delayed::Job.first.priority.should == 5 end it "should be able to set run_at when enqueuing items" do later = 5.minutes.from_now Delayed::Job.enqueue SimpleJob.new, 5, later # use be close rather than equal to because millisecond values cn be lost in DB round trip Delayed::Job.first.run_at.should be_close(later, 1) end it "should call perform on jobs when running work_off" do SimpleJob.runs.should == 0 Delayed::Job.enqueue SimpleJob.new Delayed::Job.work_off SimpleJob.runs.should == 1 end it "should set name properly according to the class" do job = Delayed::Job.enqueue SimpleJob.new job.name.should == "SimpleJob" end it "should set name properly when display_name method is defined in the object" do job = Delayed::Job.enqueue CustomJob.new job.name.should_not == "CustomJob" job.name.should == "custom name for the job" end it "should work on specified job types for common objects" do Delayed::Job.unfinished.should have(0).jobs Delayed::Job.finished.should have(0).jobs "a string".send_later :size Delayed::Job.unfinished.should have(1).jobs Delayed::Job.finished.should have(0).jobs Delayed::Job.work_off :job_types => "String#size" Delayed::Job.unfinished.should have(0).jobs Delayed::Job.finished.should have(1).job end it "should work on specified job types" do SimpleJob.runs.should == 0 Delayed::Job.enqueue SimpleJob.new Delayed::Job.work_off :job_types => "SimpleJob" SimpleJob.runs.should == 1 end it "should not work on unspecified job types" do SimpleJob.runs.should == 0 Delayed::Job.enqueue SimpleJob.new Delayed::Job.work_off :job_types => "AnotherJob" SimpleJob.runs.should == 0 end it "should work on specified job types even when it's a list" do SimpleJob.runs.should == 0 Delayed::Job.enqueue SimpleJob.new Delayed::Job.work_off :job_types => %w( Whatever SimpleJob ) SimpleJob.runs.should == 1 end it "should work with eval jobs" do $eval_job_ran = false Delayed::Job.enqueue do <<-JOB $eval_job_ran = true JOB end Delayed::Job.work_off $eval_job_ran.should == true end it "should work with jobs in modules" do M::ModuleJob.runs.should == 0 Delayed::Job.enqueue M::ModuleJob.new Delayed::Job.work_off M::ModuleJob.runs.should == 1 end it "should be destroyed if it succeeded and we want to destroy jobs" do default = Delayed::Job.destroy_successful_jobs Delayed::Job.destroy_successful_jobs = true Delayed::Job.enqueue SimpleJob.new Delayed::Job.work_off Delayed::Job.count.should == 0 Delayed::Job.destroy_successful_jobs = default end it "should be kept if it succeeded and we don't want to destroy jobs" do default = Delayed::Job.destroy_successful_jobs Delayed::Job.destroy_successful_jobs = false Delayed::Job.enqueue SimpleJob.new Delayed::Job.work_off Delayed::Job.count.should == 1 Delayed::Job.destroy_successful_jobs = default end it "should be finished if it succeeded and we don't want to destroy jobs" do default = Delayed::Job.destroy_successful_jobs Delayed::Job.destroy_successful_jobs = false @job = Delayed::Job.create :payload_object => SimpleJob.new @job.reload.finished_at.should == nil Delayed::Job.work_off @job.reload.finished_at.should_not == nil Delayed::Job.destroy_successful_jobs = default end it "should never find finished jobs" do @job = Delayed::Job.create :payload_object => SimpleJob.new, :finished_at => Time.now Delayed::Job.find_available( :limit => 1 ).length.should == 0 end it "should re-schedule by about 1 second at first and increment this more and more minutes when it fails to execute properly" do job = Delayed::Job.enqueue ErrorJob.new Delayed::Job.work_off( :n => 1 ) job.reload job.last_error.should =~ /did not work/ job.last_error.should =~ /job_spec.rb:\d+:in `perform'/ job.attempts.should == 1 job.run_at.should > Delayed::Job.db_time_now - 10.minutes job.run_at.should < Delayed::Job.db_time_now + 10.minutes end it "should raise an DeserializationError when the job class is totally unknown" do job = Delayed::Job.new job['handler'] = "--- !ruby/object:JobThatDoesNotExist {}" lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError) end it "should try to load the class when it is unknown at the time of the deserialization" do job = Delayed::Job.new job['handler'] = "--- !ruby/object:JobThatDoesNotExist {}" job.should_receive(:attempt_to_load).with('JobThatDoesNotExist').and_return(true) lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError) end it "should try include the namespace when loading unknown objects" do job = Delayed::Job.new job['handler'] = "--- !ruby/object:Delayed::JobThatDoesNotExist {}" job.should_receive(:attempt_to_load).with('Delayed::JobThatDoesNotExist').and_return(true) lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError) end it "should also try to load structs when they are unknown (raises TypeError)" do job = Delayed::Job.new job['handler'] = "--- !ruby/struct:JobThatDoesNotExist {}" job.should_receive(:attempt_to_load).with('JobThatDoesNotExist').and_return(true) lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError) end it "should try include the namespace when loading unknown structs" do job = Delayed::Job.new job['handler'] = "--- !ruby/struct:Delayed::JobThatDoesNotExist {}" job.should_receive(:attempt_to_load).with('Delayed::JobThatDoesNotExist').and_return(true) lambda { job.payload_object.perform }.should raise_error(Delayed::DeserializationError) end it "should be failed if it failed more than MAX_ATTEMPTS times and we don't want to destroy jobs" do default = Delayed::Job.destroy_failed_jobs Delayed::Job.destroy_failed_jobs = false @job = Delayed::Job.create :payload_object => SimpleJob.new, :attempts => 50 @job.reload.failed_at.should == nil @job.reschedule 'FAIL' @job.reload.failed_at.should_not == nil Delayed::Job.destroy_failed_jobs = default end it "should be destroyed if it failed more than MAX_ATTEMPTS times and we want to destroy jobs" do default = Delayed::Job.destroy_failed_jobs Delayed::Job.destroy_failed_jobs = true @job = Delayed::Job.create :payload_object => SimpleJob.new, :attempts => 50 @job.should_receive(:destroy) @job.reschedule 'FAIL' Delayed::Job.destroy_failed_jobs = default end it "should allow jobs to override max_attemps and behave appropriately" do default = Delayed::Job.destroy_failed_jobs Delayed::Job.destroy_failed_jobs = true @job = Delayed::Job.create :payload_object => CustomJob.new, :attempts => 5 @job.should_receive(:destroy) @job.reschedule 'FAIL' Delayed::Job.destroy_failed_jobs = default end it "should fail after MAX_RUN_TIME" do @job = Delayed::Job.create :payload_object => LongRunningJob.new Delayed::Job.reserve_and_run_one_job( :max_run_time => 1.second ) @job.reload.last_error.should =~ /expired/ @job.attempts.should == 1 end it "should find high priority jobs first" do @job_10 = Delayed::Job.create :payload_object => SimpleJob.new, :priority => 10 @job_20 = Delayed::Job.create :payload_object => SimpleJob.new, :priority => 20 Delayed::Job.find_available( :limit => 1 ).first.should == @job_20 end it "should find only jobs like the parameter given" do Delayed::Job.create :payload_object => SimpleJob.new Delayed::Job.create :payload_object => CustomJob.new Delayed::Job.unfinished.should have(2).jobs Delayed::Job.work_off :only_for => 'Simple' Delayed::Job.unfinished.should have(1).jobs Delayed::Job.work_off :only_for => 'custom' Delayed::Job.unfinished.should have(0).jobs end it "should never find failed jobs" do @job = Delayed::Job.create :payload_object => SimpleJob.new, :attempts => 50, :failed_at => Time.now Delayed::Job.find_available( :limit => 1 ).length.should == 0 end context "when another worker is already performing an task, it" do before :each do @job = Delayed::Job.create :payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => Delayed::Job.db_time_now - 5.minutes end it "should not allow a second worker to get exclusive access" do @job.lock_exclusively!(4.hours, 'worker2').should == false end it "should allow a second worker to get exclusive access if the timeout has passed" do @job.lock_exclusively!(1.minute, 'worker2').should == true end it "should be able to get access to the task if it was started more then max_age ago" do @job.locked_at = 5.hours.ago @job.save @job.lock_exclusively! 4.hours, 'worker2' @job.reload @job.locked_by.should == 'worker2' @job.locked_at.should > 1.minute.ago end it "should not be found by another worker" do Delayed::Job.find_available( :worker_name => 'worker2', :max_run_time => 6.minutes ).should have(0).jobs end it "should be found by another worker if the time has expired" do Delayed::Job.find_available( :worker_name => 'worker2', :max_run_time => 4.minutes ).should have(1).job end it "should be able to get exclusive access again when the worker name is the same" do @job.lock_exclusively! 5.minutes, 'worker1' @job.lock_exclusively! 5.minutes, 'worker1' @job.lock_exclusively! 5.minutes, 'worker1' end end context "#name" do it "should be the class name of the job that was enqueued" do Delayed::Job.create(:payload_object => ErrorJob.new ).name.should == 'ErrorJob' end it "should be the method that will be called if its a performable method object" do Delayed::Job.send_later(:clear_locks!) Delayed::Job.last.name.should == 'Delayed::Job.clear_locks!' end it "should be the instance method that will be called if its a performable method object" do story = Story.create :text => "..." story.send_later(:save) Delayed::Job.last.name.should == 'Story#save' end end context "worker prioritization" do it "should only work_off jobs that are >= min_priority" do SimpleJob.runs.should == 0 Delayed::Job.enqueue SimpleJob.new, -10 Delayed::Job.enqueue SimpleJob.new, 0 Delayed::Job.work_off :min_priority => -5, :max_priority => 5 SimpleJob.runs.should == 1 end it "should only work_off jobs that are <= max_priority" do SimpleJob.runs.should == 0 Delayed::Job.enqueue SimpleJob.new, 10 Delayed::Job.enqueue SimpleJob.new, 0 Delayed::Job.work_off :min_priority => -5, :max_priority => 5 SimpleJob.runs.should == 1 end end context "when pulling jobs off the queue for processing, it" do before(:each) do @job = Delayed::Job.create( :payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => Delayed::Job.db_time_now - 5.minutes ) end it "should leave the queue in a consistent state and not run the job if locking fails" do SimpleJob.runs.should == 0 @job.stub!(:lock_exclusively!).with(any_args).once.and_return(false) Delayed::Job.should_receive(:find_available).once.and_return([@job]) Delayed::Job.work_off( :n => 1 ) SimpleJob.runs.should == 0 end end context "while running alongside other workers that locked jobs, it" do before(:each) do Delayed::Job.create :payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 1.minutes) Delayed::Job.create :payload_object => SimpleJob.new, :locked_by => 'worker2', :locked_at => (Delayed::Job.db_time_now - 1.minutes) Delayed::Job.create :payload_object => SimpleJob.new Delayed::Job.create :payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 1.minutes) end it "should ingore locked jobs from other workers" do SimpleJob.runs.should == 0 Delayed::Job.work_off :worker_name => 'worker3' SimpleJob.runs.should == 1 # runs the one open job end it "should find only ur own jobs unless they are locked" do SimpleJob.runs.should == 0 Delayed::Job.work_off :worker_name => 'worker1' SimpleJob.runs.should == 1 # runs open job, no worker1 jobs that were already locked end end context "while running with locked and expired jobs, it" do before(:each) do exp_time = Delayed::Job.db_time_now - (1.minutes + Delayed::Job::MAX_RUN_TIME) Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => exp_time) Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker2', :locked_at => (Delayed::Job.db_time_now - 1.minutes)) Delayed::Job.create(:payload_object => SimpleJob.new) Delayed::Job.create(:payload_object => SimpleJob.new, :locked_by => 'worker1', :locked_at => (Delayed::Job.db_time_now - 1.minutes)) end it "should only find unlocked and expired jobs" do SimpleJob.runs.should == 0 Delayed::Job.work_off :worker_name => 'worker3' SimpleJob.runs.should == 2 # runs the one open job and one expired job end it "should ignore locks when finding our own jobs" do SimpleJob.runs.should == 0 Delayed::Job.work_off :worker_name => 'worker1' SimpleJob.runs.should == 2 # runs open job plus worker1 jobs (unless locked) end end end