├── .document ├── .gitignore ├── Gemfile ├── Gemfile.lock ├── History.txt ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── resque-multi-step.rb ├── resque │ └── plugins │ │ ├── multi_step_task.rb │ │ └── multi_step_task │ │ ├── assure_finalization.rb │ │ ├── atomic_counters.rb │ │ ├── constantization.rb │ │ ├── finalization_job.rb │ │ └── version.rb └── resque_mutli_step.rb ├── resque-multi-step.gemspec └── spec ├── acceptance ├── acceptance_jobs.rb ├── job_handling_spec.rb └── spec_helper.rb ├── resque-multi-step_spec.rb ├── resque └── plugins │ ├── multi_step_task │ └── finalization_job_spec.rb │ └── multi_step_task_spec.rb ├── spec.opts └── spec_helper.rb /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS 2 | .DS_Store 3 | 4 | ## TEXTMATE 5 | *.tmproj 6 | tmtags 7 | 8 | ## EMACS 9 | *~ 10 | \#* 11 | .\#* 12 | 13 | ## VIM 14 | *.swp 15 | 16 | ## PROJECT::GENERAL 17 | coverage 18 | rdoc 19 | pkg 20 | 21 | ## PROJECT::SPECIFIC 22 | .rvmrc 23 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in resque-multi-step.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | resque-multi-step (2.0.9) 5 | redis-namespace 6 | resque 7 | resque-fairly 8 | yajl-ruby 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | diff-lcs (1.2.2) 14 | mono_logger (1.1.0) 15 | multi_json (1.7.2) 16 | rack (1.5.2) 17 | rack-protection (1.5.3) 18 | rack 19 | rake (10.3.2) 20 | redis (3.2.1) 21 | redis-namespace (1.5.2) 22 | redis (~> 3.0, >= 3.0.4) 23 | resque (1.25.2) 24 | mono_logger (~> 1.0) 25 | multi_json (~> 1.0) 26 | redis-namespace (~> 1.3) 27 | sinatra (>= 0.9.2) 28 | vegas (~> 0.1.2) 29 | resque-fairly (1.4.1) 30 | resque (~> 1.0) 31 | rspec (2.13.0) 32 | rspec-core (~> 2.13.0) 33 | rspec-expectations (~> 2.13.0) 34 | rspec-mocks (~> 2.13.0) 35 | rspec-core (2.13.1) 36 | rspec-expectations (2.13.0) 37 | diff-lcs (>= 1.1.3, < 2.0) 38 | rspec-mocks (2.13.0) 39 | sinatra (1.4.6) 40 | rack (~> 1.4) 41 | rack-protection (~> 1.4) 42 | tilt (>= 1.3, < 3) 43 | tilt (2.0.1) 44 | vegas (0.1.11) 45 | rack (>= 1.0.0) 46 | yajl-ruby (1.2.1) 47 | 48 | PLATFORMS 49 | ruby 50 | 51 | DEPENDENCIES 52 | rake (~> 10.0) 53 | resque-multi-step! 54 | rspec (~> 2.13) 55 | -------------------------------------------------------------------------------- /History.txt: -------------------------------------------------------------------------------- 1 | Version 2.0.0 2 | ===== 3 | 4 | Add support to reference the original multi-step-task job from within the job classes. 5 | 6 | Version 1.1.3 7 | ===== 8 | 9 | Bump supported version of rspec, redis-namespace, resque and resque-fairly (Gonzalo Rodríguez-Baltanás Díaz) 10 | 11 | Version 1.1.2 12 | ===== 13 | 14 | Fixed race condition around task finalization which caused some jobs to fail FinalizationAlreadyBegun errors. This did not negatively impact task completion but it did clutter the failed job queue. 15 | 16 | Version 1.0.0 17 | ======= 18 | 19 | Initial port of internal code to support multi step tasks. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) OpenLogic, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | resque-multi-step 2 | ====== 3 | 4 | Resque multi-step provides an abstraction for managing multiple step 5 | async tasks. 6 | 7 | Status 8 | ---- 9 | 10 | This software is not considered stable at this time. Use at your own risk. 11 | 12 | Using multi-step tasks 13 | ---- 14 | 15 | Consider a situation where you need to perform several actions and 16 | would like those actions to run in parallel and you would like to keep 17 | track of the progress. Mutli-step can be use to implement that as 18 | follows: 19 | 20 | task = Resque::Plugins::MultiStepTask.create("pirate-take-over") do |task| 21 | blog.posts.each do |post| 22 | task.add_job ConvertPostToPirateTalk, post.id 23 | end 24 | end 25 | 26 | A resque job will be queued for each post. The `task` object will 27 | keep track of how many of the tasks have been completed successfully 28 | (`#completed_count`). That combined with the overall job count 29 | (`#total_job_count`) make it easy to compute the percentage completion 30 | of a mutli-step task. 31 | 32 | The failed job count (`#failed_count`) makes it easy to determine if 33 | problem has occurred during the execution. 34 | 35 | Looking up existing tasks 36 | ---- 37 | 38 | Once you have kicked off a job you can look it up again later using 39 | it's task id. First you persist the task id when you create the task. 40 | 41 | task = Resque::Plugins::MultiStepTask.create('pirate-take-over") do |task| 42 | ... 43 | end 44 | blog.async_task_id = task.task_id 45 | blog.save! 46 | 47 | Then you can look it up using the `.find` method on `MultiStepTask`. 48 | 49 | # Progress reporting action; executed in a different process. 50 | begin 51 | task = Resque::Plugins::MultiStepTask.find(blog.async_task_id) 52 | render :text => "percent complete #{(task.completed_count.quo(task.total_job_count) * 100).round}% 53 | 54 | rescue Resque::Plugins::MultiStepTask::NoSuchMultiStepTask 55 | # task completed... 56 | 57 | redirect_to blog_url(blog) 58 | end 59 | 60 | As of version 2.0.0, you can also get a handle on the original multi-step 61 | task. This is useful if you need to add another job on the fly, that needs 62 | to happen before the finalization. 63 | 64 | class ConvertPostToPirateTalk 65 | def self.perform(post_id) 66 | p = Post.find(post_id) 67 | p.convert_to_pirate_talk 68 | p.save 69 | 70 | if p.has_comments? 71 | p.comments.each do |c| 72 | # #multi_step_task is a class method defined on the class 73 | # before invoking #perform as a convenience. you can also 74 | # use #multi_step_task_id to just get the resque key. 75 | multi_step_task.add_job ConvertCommentToPirateTalk, c.id 76 | end 77 | end 78 | end 79 | end 80 | 81 | 82 | Finalization 83 | ---- 84 | 85 | Often when doing mutli-step tasks there are a bunch of tasks that can 86 | all happen in parallel and then a few that can only be executed after 87 | all the rest have completed. Mutli-step task finalization supports 88 | just that use case. 89 | 90 | Using our example, say we want to commit the solr index and then 91 | unlock the blog we are converting to pirate talk once the conversion 92 | is complete. 93 | 94 | task = Resque::Plugins::MultiStepTask.create("pirate-take-over") do |task| 95 | blog.posts.each do |post| 96 | task.add_job ConvertPostToPirateTalk, post.id 97 | end 98 | 99 | task.add_finalization_job CommitSolr 100 | task.add_finalization_job UnlockBlog, blog.id 101 | end 102 | 103 | This would convert all the posts to pirate talk in parallel, using as 104 | many workers as are available. Once all the normal jobs are completed 105 | the finalization jobs are run serially in a single worker. 106 | Finalization are executed in the order in which they are registered. 107 | In our example, solr will be committed and then, after the commit is 108 | complete, the blog will be unlocked. 109 | 110 | Details 111 | ---- 112 | 113 | MultiStepTask creates a queue in resque for each task. To process 114 | multi-step jobs you will need at least one Resque worker with 115 | `QUEUES=*`. This combined with [resque-fairly][] provides fair 116 | scheduling of the constituent jobs. 117 | 118 | Having a queue per multi-step task means that is easy to determine to 119 | what task a particular job belongs. It also provides a nice way to see 120 | what is going on in the system at any given time. Just got to 121 | resque-web and look the queue list. Use meaningful slugs for your 122 | tasks and you get a quick birds-eye view of what is going on. 123 | 124 | As of version 2.0.0 MultiStepTask requires ruby 1.9 or higher. 125 | 126 | Note on Patches/Pull Requests 127 | ---- 128 | 129 | * Fork the project. 130 | * Make your feature addition or bug fix. 131 | * Add tests for it. This is important so I don't break it in a 132 | future version unintentionally. 133 | * Update history to reflect the change, increment the version properly as described at . 134 | * Commit 135 | * Send me a pull request. Bonus points for topic branches. 136 | 137 | Mailing List 138 | ---- 139 | 140 | To join the list simply send an email to . This will subscribe you and send you information about your subscription, including unsubscribe information. 141 | 142 | The archive can be found at . 143 | 144 | Copyright 145 | ----- 146 | 147 | Copyright (c) OpenLogic, Inc. See LICENSE for details. 148 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task :default => :spec 8 | 9 | require 'rdoc/task' 10 | Rake::RDocTask.new do |rdoc| 11 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 12 | 13 | rdoc.rdoc_dir = 'rdoc' 14 | rdoc.title = "resque-multi-step #{version}" 15 | rdoc.rdoc_files.include('README*') 16 | rdoc.rdoc_files.include('lib/**/*.rb') 17 | end 18 | 19 | # Setup for acceptance testing 20 | require 'rubygems' 21 | require 'resque/tasks' 22 | require 'resque-fairly' 23 | 24 | Resque.redis.namespace = ENV['NAMESPACE'] if ENV['NAMESPACE'] 25 | 26 | $LOAD_PATH << File.expand_path("lib", File.dirname(__FILE__)) 27 | require 'resque-multi-step' 28 | 29 | $LOAD_PATH << File.expand_path("spec/acceptance", File.dirname(__FILE__)) 30 | require 'acceptance_jobs' 31 | 32 | -------------------------------------------------------------------------------- /lib/resque-multi-step.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'resque/plugins/multi_step_task' 3 | -------------------------------------------------------------------------------- /lib/resque/plugins/multi_step_task.rb: -------------------------------------------------------------------------------- 1 | require 'resque' 2 | require 'redis-namespace' 3 | require 'resque/plugins/multi_step_task/assure_finalization' 4 | require 'resque/plugins/multi_step_task/finalization_job' 5 | require 'resque/plugins/multi_step_task/constantization' 6 | require 'resque/plugins/multi_step_task/atomic_counters' 7 | require 'logger' 8 | require 'yajl' 9 | 10 | module Resque 11 | module Plugins 12 | # @attr_reader normal_job_count 13 | # @attr_reader finalize_job_count 14 | # @attr_reader completed_count 15 | # @attr_reader failed_count 16 | class MultiStepTask 17 | class NoSuchMultiStepTask < StandardError; end 18 | class NotReadyForFinalization < StandardError; end 19 | class FinalizationAlreadyBegun < StandardError; end 20 | class StdOutLogger 21 | def warn(*args); puts args; end 22 | def info(*args); puts args; end 23 | def debug(*args); puts args; end 24 | end 25 | 26 | class << self 27 | include Constantization 28 | 29 | NONCE_CHARS = ('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a 30 | 31 | # A bit of randomness to ensure tasks are uniquely identified. 32 | def nonce 33 | 5.times.map{NONCE_CHARS.sample}.join 34 | end 35 | 36 | # A redis client suitable for storing global mutli-step task info. 37 | def redis 38 | @redis ||= Redis::Namespace.new("resque:multisteptask", :redis => Resque.redis) 39 | end 40 | 41 | # Does a task with the specified id exist? 42 | def active?(task_id) 43 | redis.sismember("active-tasks", task_id) 44 | end 45 | 46 | # Create a brand new multi-step-task. 47 | # 48 | # @param [#to_s] slug The descriptive slug of the new job. Default: a 49 | # random UUID 50 | # 51 | # @yield [multi_step_task] A block to define the work to take place in parallel 52 | # 53 | # @yieldparam [MultiStepTask] The newly create job group. 54 | # 55 | # @return [MultiStepTask] The new job group 56 | def create(slug=nil) 57 | task_id = if slug.nil? || slug.empty? 58 | "multi-step-task" 59 | else 60 | slug.to_s 61 | end 62 | task_id << "~" << nonce 63 | 64 | mst = new(task_id) 65 | mst.nuke 66 | redis.sadd("active-tasks", task_id) 67 | if block_given? 68 | yield mst 69 | mst.finalizable! 70 | end 71 | 72 | mst 73 | end 74 | 75 | # Prevent calling MultiStepTask.new 76 | private :new 77 | 78 | # Find an existing MultiStepTask. 79 | # 80 | # @param [#to_s] task_id The unique key for the job group of interest. 81 | # 82 | # @return [MultiStepTask] The group of interest 83 | # 84 | # @raise [NoSuchMultiStepTask] If there is not a group with the specified key. 85 | def find(task_id) 86 | raise NoSuchMultiStepTask unless active?(task_id) 87 | 88 | mst = new(task_id) 89 | end 90 | 91 | # Handle job invocation 92 | def perform(task_id, job_module_name, *args) 93 | task = perform_without_maybe_finalize(task_id, job_module_name, *args) 94 | task.maybe_finalize 95 | end 96 | 97 | def perform_without_maybe_finalize(task_id, job_module_name, *args) 98 | task = MultiStepTask.find(task_id) 99 | begin 100 | start_time = Time.now 101 | logger.debug("[Resque Multi-Step-Task] Executing #{job_module_name} job for #{task_id} at #{start_time} (args: #{args})") 102 | 103 | # perform the task 104 | klass = constantize(job_module_name) 105 | 106 | klass.singleton_class.class_eval "def multi_step_task; MultiStepTask.find(multi_step_task_id); end" unless 107 | klass.singleton_class.method_defined? :multi_step_task 108 | klass.singleton_class.class_eval "def multi_step_task_id; '#{task_id}'; end" 109 | 110 | klass.perform(*args) 111 | 112 | logger.debug("[Resque Multi-Step-Task] Finished executing #{job_module_name} job for #{task_id} at #{Time.now}, taking #{(Time.now - start_time)} seconds.") 113 | rescue Exception => e 114 | logger.error("[Resque Multi-Step-Task] #{job_module_name} job failed for #{task_id} at #{Time.now} (args: #{args})") 115 | logger.info("[Resque Multi-Step-Task] Incrementing failed_count: #{job_module_name} job failed for task id #{task_id} at #{Time.now} (args: #{args})") 116 | task.increment_failed_count 117 | raise 118 | end 119 | logger.info("[Resque Multi-Step-Task] Incrementing completed_count: #{job_module_name} job completed for task id #{task_id} at #{Time.now} (args: #{args})") 120 | task.increment_completed_count 121 | task 122 | end 123 | 124 | def perform_finalization(task_id, job_module_name, *args) 125 | perform_without_maybe_finalize(task_id, job_module_name, *args) 126 | end 127 | 128 | def logger=(logger) 129 | @@logger = logger 130 | end 131 | 132 | def logger 133 | @@logger ||= Logger.new(STDERR) 134 | end 135 | 136 | # Normally jobs that are part of a multi-step task are run 137 | # asynchronously by putting them on a queue. However, it is 138 | # often more convenient to just run the jobs synchronously as 139 | # they are registered in a development environment. Setting 140 | # mode to `:sync` provides a way to do just that. 141 | # 142 | # @param [:sync,:async] sync_or_async 143 | def mode=(sync_or_async) 144 | @@synchronous = (sync_or_async == :sync) 145 | end 146 | 147 | def synchronous? 148 | @@synchronous 149 | end 150 | @@synchronous = false 151 | 152 | end 153 | 154 | def synchronous? 155 | @@synchronous 156 | end 157 | 158 | # Instance methods 159 | 160 | include Constantization 161 | 162 | attr_reader :task_id 163 | attr_accessor :logger 164 | 165 | extend AtomicCounters 166 | 167 | counter :normal_job_count 168 | 169 | counter :finalize_job_count 170 | 171 | counter :completed_count 172 | 173 | counter :failed_count 174 | 175 | 176 | # Initialize a newly instantiated parallel job group. 177 | # 178 | # @param [String] task_id The UUID of the group of interest. 179 | def initialize(task_id) 180 | @task_id = task_id 181 | redis.setnx 'start-time', Time.now.to_i 182 | end 183 | 184 | def logger 185 | self.class.logger 186 | end 187 | 188 | def redis 189 | @redis ||= Redis::Namespace.new("resque:multisteptask:#{task_id}", :redis => Resque.redis) 190 | end 191 | 192 | # The total number of jobs that are part of this task. 193 | def total_job_count 194 | normal_job_count + finalize_job_count 195 | end 196 | 197 | # Removes all data from redis related to this task. 198 | def nuke 199 | redis.keys('*').each{|k| redis.del k} 200 | Resque.remove_queue queue_name 201 | self.class.redis.srem('active-tasks', task_id) 202 | end 203 | 204 | # The name of the queue for jobs what are part of this task. 205 | def queue_name 206 | task_id 207 | end 208 | 209 | # Add a job to this task 210 | # 211 | # @param [Class,Module] job_type The type of the job to be performed. 212 | def add_job(job_type, *args) 213 | logger.info("[Resque Multi-Step-Task] Incrementing normal_job_count: #{job_type} job added to task id #{task_id} at #{Time.now} (args: #{args})") 214 | 215 | increment_normal_job_count 216 | logger.debug("[Resque Multi-Step-Task] Adding #{job_type} job for #{task_id} (args: #{args})") 217 | 218 | if synchronous? 219 | self.class.perform(task_id, job_type.to_s, *args) 220 | else 221 | Resque::Job.create(queue_name, self.class, task_id, job_type.to_s, *args) 222 | end 223 | end 224 | 225 | # Finalization jobs are performed after all the normal jobs 226 | # (i.e. the ones registered with #add_job) have been completed. 227 | # Finalization jobs are performed in the order they are defined. 228 | # 229 | # @param [Class,Module] job_type The type of job to be performed. 230 | def add_finalization_job(job_type, *args) 231 | logger.info("[Resque Multi-Step-Task] Incrementing finalize_job_count: Finalization job #{job_type} for task id #{task_id} at #{Time.now} (args: #{args})") 232 | increment_finalize_job_count 233 | logger.debug("[Resque Multi-Step-Task] Adding #{job_type} finalization job for #{task_id} (args: #{args})") 234 | 235 | redis.rpush 'finalize_jobs', Yajl::Encoder.encode([job_type.to_s, *args]) 236 | end 237 | 238 | # A multi-step task is finalizable when all the normal jobs (see 239 | # #add_job) have been registered. Finalization jobs will not be 240 | # executed until the task becomes finalizable regardless of the 241 | # number of jobs that have been completed. 242 | def finalizable? 243 | redis.exists 'is_finalizable' 244 | end 245 | 246 | # Make this multi-step task finalizable (see #finalizable?). 247 | def finalizable! 248 | redis.set 'is_finalizable', true 249 | if synchronous? 250 | maybe_finalize 251 | else 252 | # finalization happens after normal jobs, but in the wierd case where 253 | # there are only finalization jobs, we need to add a fake normal job 254 | # that just kicks off the finalization process 255 | # 256 | # due to race conditions, always assure finalization - DCM 257 | assure_finalization #if normal_job_count == 0 258 | end 259 | end 260 | 261 | def assure_finalization 262 | Resque::Job.create(queue_name, AssureFinalization, self.task_id) 263 | end 264 | 265 | # Finalize this job group. Finalization entails running all 266 | # finalization jobs serially in the order they were defined. 267 | # 268 | # @raise [NotReadyForFinalization] When called before all normal 269 | # jobs have been attempted. 270 | # 271 | # @raise [FinalizationAlreadyBegun] If some other process has 272 | # already started (and/or finished) the finalization process. 273 | def finalize! 274 | logger.debug("[Resque Multi-Step-Task] Attempting to finalize #{task_id}") 275 | raise FinalizationAlreadyBegun unless MultiStepTask.active?(task_id) 276 | raise NotReadyForFinalization if !ready_for_finalization? || incomplete_because_of_errors? 277 | 278 | # Only one process is allowed to start the finalization 279 | # process. This setnx acts a global mutex for other processes 280 | # that finish about the same time. 281 | raise FinalizationAlreadyBegun unless redis.setnx("i_am_the_finalizer", 1) 282 | 283 | if synchronous? 284 | sync_finalize! 285 | else 286 | if fin_job_info = redis.lpop('finalize_jobs') 287 | fin_job_info = Yajl::Parser.parse(fin_job_info) 288 | Resque::Job.create(queue_name, FinalizationJob, self.task_id, *fin_job_info) 289 | else 290 | # There is nothing left to do so cleanup. 291 | logger.debug "[Resque Multi-Step-Task] \"#{task_id}\" finalized successfully at #{Time.now}, taking #{(Time.now - redis.get('start-time').to_i).to_i} seconds." 292 | nuke 293 | end 294 | end 295 | end 296 | 297 | def sync_finalize! 298 | while fin_job_info = redis.lpop('finalize_jobs') 299 | job_class_name, *args = Yajl::Parser.parse(fin_job_info) 300 | self.class.perform_finalization(task_id, job_class_name, *args) 301 | end 302 | 303 | logger.debug "[Resque Multi-Step-Task] \"#{task_id}\" finalized successfully at #{Time.now}, taking #{(Time.now - redis.get('start-time').to_i).to_i} seconds." 304 | nuke 305 | end 306 | 307 | # Execute finalization sequence if it is time. 308 | def maybe_finalize 309 | return unless ready_for_finalization? && !incomplete_because_of_errors? 310 | finalize! 311 | rescue FinalizationAlreadyBegun 312 | # Just eat it the exception. Sometimes multiple normal jobs 313 | # will try to finalize a task simultaneously. This is 314 | # expected behavior because normal jobs run in parallel. 315 | end 316 | 317 | # Is this task at the point where finalization can occur. 318 | def ready_for_finalization? 319 | finalizable? && completed_count >= normal_job_count 320 | end 321 | 322 | # If a normal or finalization job fails (i.e. raises an 323 | # exception) the task as a whole is considered to be incomplete. 324 | # The finalization sequence will not be performed. If the 325 | # failure occurred during finalization any remaining 326 | # finalization job will not be run. 327 | # 328 | # If the failed job is retried and succeeds finalization will 329 | # proceed at usual. 330 | def incomplete_because_of_errors? 331 | failed_count > 0 && completed_count < normal_job_count 332 | end 333 | 334 | def unfinalized_because_of_errors? 335 | failed_count > 0 && completed_count < (normal_job_count + finalize_job_count) 336 | end 337 | 338 | end 339 | end 340 | end 341 | 342 | -------------------------------------------------------------------------------- /lib/resque/plugins/multi_step_task/assure_finalization.rb: -------------------------------------------------------------------------------- 1 | module Resque 2 | module Plugins 3 | class MultiStepTask 4 | # in the case that all normal jobs have completed before the job group 5 | # is finalized, the job group will never receive the hook to enter 6 | # finalizataion. To avoid this, an AssureFinalization job will be added 7 | # to the queue for the sole purposed of initiating finalization for certain. 8 | class AssureFinalization 9 | def self.perform(task_id) 10 | MultiStepTask.find(task_id).maybe_finalize 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/resque/plugins/multi_step_task/atomic_counters.rb: -------------------------------------------------------------------------------- 1 | module Resque 2 | module Plugins 3 | class MultiStepTask 4 | module AtomicCounters 5 | def counter(name) 6 | class_eval <<-INCR 7 | def increment_#{name} 8 | redis.incrby('#{name}', 1) 9 | logger.info("[Resque Multi-Step-Task] Incremented #{name}") 10 | end 11 | INCR 12 | 13 | class_eval <<-GETTER 14 | def #{name} 15 | redis.get('#{name}').to_i 16 | end 17 | GETTER 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/resque/plugins/multi_step_task/constantization.rb: -------------------------------------------------------------------------------- 1 | module Resque 2 | module Plugins 3 | class MultiStepTask 4 | module Constantization 5 | # Courtesy ActiveSupport (Ruby on Rails) 6 | def constantize(camel_cased_word) 7 | names = camel_cased_word.split('::') 8 | names.shift if names.empty? || names.first.empty? 9 | 10 | constant = Object 11 | names.each do |name| 12 | constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name) 13 | end 14 | constant 15 | end 16 | end 17 | end 18 | end 19 | end 20 | 21 | -------------------------------------------------------------------------------- /lib/resque/plugins/multi_step_task/finalization_job.rb: -------------------------------------------------------------------------------- 1 | require 'resque/plugins/multi_step_task/constantization' 2 | 3 | module Resque 4 | module Plugins 5 | class MultiStepTask 6 | # Executes a single finalization job 7 | class FinalizationJob 8 | extend Constantization 9 | 10 | # Handle job invocation 11 | def self.perform(task_id, job_module_name, *args) 12 | task = MultiStepTask.find(task_id) 13 | 14 | begin 15 | klass = constantize(job_module_name) 16 | klass.singleton_class.class_eval "def multi_step_task; @@task ||= MultiStepTask.find('#{task_id}'); end" 17 | klass.singleton_class.class_eval "def multi_step_task_id; @@task_id ||= '#{task_id}'; end" 18 | klass.perform(*args) 19 | rescue Exception 20 | logger.info("[Resque Multi-Step-Task] Incrementing failed_count: Finalization job #{job_module_name} failed for task id #{task_id} at #{Time.now} (args: #{args})") 21 | task.increment_failed_count 22 | raise 23 | end 24 | logger.info("[Resque Multi-Step-Task] Incrementing completed_count: Finalization job #{job_module_name} completed for task id #{task_id} at #{Time.now} (args: #{args})") 25 | task.increment_completed_count 26 | 27 | if fin_job_info = task.redis.lpop('finalize_jobs') 28 | # Queue the next finalization job 29 | Resque::Job.create(task.queue_name, FinalizationJob, task.task_id, 30 | *Yajl::Parser.parse(fin_job_info)) 31 | else 32 | # There is nothing left to do so cleanup. 33 | task.nuke 34 | end 35 | 36 | end 37 | 38 | def self.logger 39 | Resque::Plugins::MultiStepTask.logger 40 | end 41 | end 42 | end 43 | end 44 | end 45 | 46 | -------------------------------------------------------------------------------- /lib/resque/plugins/multi_step_task/version.rb: -------------------------------------------------------------------------------- 1 | module Resque 2 | module Plugins 3 | class MultiStepTask 4 | VERSION = "2.0.9" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/resque_mutli_step.rb: -------------------------------------------------------------------------------- 1 | require 'resque-multi-step' 2 | -------------------------------------------------------------------------------- /resque-multi-step.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'resque/plugins/multi_step_task/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'resque-multi-step' 8 | spec.version = Resque::Plugins::MultiStepTask::VERSION 9 | spec.authors = ['Peter Williams', 'Morgan Whitney', 'Jeff Gran', 'Cameron Mauch'] 10 | spec.summary = 'Provides multi-step tasks with finalization and progress tracking' 11 | spec.description = 'Provides multi-step tasks with finalization and progress tracking' 12 | spec.homepage = 'https://github.com/openlogic/resque-multi-step' 13 | 14 | spec.files = `git ls-files -z`.split("\x0") 15 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 16 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 17 | spec.require_paths = ['lib'] 18 | 19 | spec.add_dependency 'redis-namespace' 20 | spec.add_dependency 'yajl-ruby' 21 | spec.add_dependency 'resque' 22 | spec.add_dependency 'resque-fairly' 23 | 24 | spec.add_development_dependency 'rake', '~> 10.0' 25 | spec.add_development_dependency 'rspec', '~> 2.13' 26 | end 27 | -------------------------------------------------------------------------------- /spec/acceptance/acceptance_jobs.rb: -------------------------------------------------------------------------------- 1 | module MultiStepAcceptance 2 | class WaitJob 3 | def self.perform(duration) 4 | sleep duration 5 | end 6 | end 7 | 8 | class CounterJob 9 | def self.perform(key) 10 | Resque.redis.incr key 11 | end 12 | end 13 | 14 | class FailJob 15 | def self.perform 16 | raise "boom" 17 | end 18 | end 19 | 20 | class FailOnceJob 21 | def self.perform(key) 22 | if Resque.redis.exists(key) 23 | return 24 | else 25 | Resque.redis.set(key, 'was here') 26 | raise "boom" 27 | end 28 | end 29 | end 30 | 31 | class BackRefJob 32 | def self.perform 33 | str = multi_step_task_id 34 | mst = multi_step_task 35 | end 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /spec/acceptance/job_handling_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("spec_helper", File.dirname(__FILE__)) 2 | require 'resque/plugins/multi_step_task' 3 | 4 | # These tests are inherently sensitive to timing. There are sleeps a 5 | # the appropriate places to reduce/eliminate false failures where 6 | # possible. However, a failure does not always indicate a bug. Run 7 | # the test multiple times before accepting failures at face value. 8 | 9 | describe "Acceptance: Successful task" do 10 | let(:task) { 11 | Resque::Plugins::MultiStepTask.create("testing") do |task| 12 | task.add_job MultiStepAcceptance::CounterJob, "testing counter" 13 | end 14 | } 15 | 16 | before do 17 | Resque.redis.del "testing counter" 18 | task 19 | sleep 1 20 | end 21 | 22 | it "processes its job" do 23 | Resque.redis.get("testing counter").to_i.should == 1 24 | end 25 | 26 | it "removes queue when done" do 27 | Resque.queues.should_not include(task.queue_name) 28 | end 29 | end 30 | 31 | describe "Acceptance: Successful tasks" do 32 | let(:task) { 33 | Resque::Plugins::MultiStepTask.create("testing") do |task| 34 | task.add_job MultiStepAcceptance::WaitJob, 1 35 | end 36 | } 37 | 38 | before {task} 39 | 40 | it "create queue" do 41 | Resque.queues.should include(task.queue_name) 42 | end 43 | 44 | it "queues jobs in its queue" do 45 | Resque.peek(task.queue_name).should_not be_nil 46 | end 47 | 48 | end 49 | 50 | describe "Acceptance: Task with step failure" do 51 | let(:task) { 52 | Resque::Plugins::MultiStepTask.create("testing") do |task| 53 | task.add_job MultiStepAcceptance::FailJob 54 | end 55 | } 56 | 57 | before do 58 | Resque::Failure.clear 59 | task 60 | sleep 1 61 | end 62 | 63 | it "put job in fail list" do 64 | Resque::Failure.count.should == 1 65 | end 66 | 67 | end 68 | 69 | describe "Acceptance: Task with finalization failure" do 70 | let(:task) { 71 | Resque.redis.del "testing counter" 72 | Resque::Plugins::MultiStepTask.create("testing") do |task| 73 | task.add_finalization_job MultiStepAcceptance::FailJob 74 | end 75 | } 76 | 77 | before do 78 | Resque::Failure.clear 79 | task 80 | sleep 1 81 | end 82 | 83 | it "put job in fail list" do 84 | 5.times {sleep 1 if Resque::Failure.count < 1} 85 | 86 | Resque::Failure.count.should == 1 87 | end 88 | end 89 | 90 | describe "Acceptance: Task with retried finalization failure" do 91 | let(:task) { 92 | @counter_key = "testing-counter-#{Time.now.to_i}" 93 | @fin_key = "fin-key-#{Time.now.to_i}" 94 | Resque.redis.del @counter_key 95 | Resque.redis.del @fin_key 96 | Resque::Plugins::MultiStepTask.create("testing") do |task| 97 | task.add_finalization_job MultiStepAcceptance::FailOnceJob, @fin_key 98 | task.add_finalization_job MultiStepAcceptance::CounterJob, @counter_key 99 | end 100 | } 101 | 102 | before do 103 | Resque::Failure.clear 104 | 105 | task 106 | sleep 5 107 | 108 | Resque::Failure.requeue 0 109 | sleep 5 110 | end 111 | 112 | it "completes task" do 113 | lambda { 114 | Resque::Plugins::MultiStepTask.find(task.task_id) 115 | }.should raise_error(Resque::Plugins::MultiStepTask::NoSuchMultiStepTask) 116 | end 117 | 118 | 119 | it "runs following finalization jobs" do 120 | Resque.redis.get(@counter_key).should == "1" 121 | end 122 | 123 | end 124 | 125 | describe "Acceptance: Task needing to reference the original Multi-Step-Task" do 126 | let(:task) do 127 | Resque::Plugins::MultiStepTask.create("testing") do |task| 128 | task.add_job MultiStepAcceptance::BackRefJob 129 | task.add_finalization_job MultiStepAcceptance::BackRefJob 130 | end 131 | end 132 | 133 | before do 134 | Resque.redis.del "testing" 135 | task 136 | sleep 1 137 | end 138 | 139 | it "does not fail" do 140 | 5.times {sleep 1 if Resque::Failure.count < 1} 141 | 142 | Resque::Failure.count.should == 0 143 | end 144 | 145 | it "completes successfully and removes the queue" do 146 | 5.times {sleep 1 if Resque::Failure.count < 1} 147 | Resque.queues.should_not include(task.queue_name) 148 | end 149 | end 150 | 151 | describe "Acceptance: Finalization always runs" do 152 | let(:task) do 153 | Resque::Plugins::MultiStepTask.create("testing") do |task| 154 | task.add_job MultiStepAcceptance::CounterJob, "testing counter" 155 | task.add_job MultiStepAcceptance::CounterJob, "testing counter" 156 | task.add_finalization_job MultiStepAcceptance::CounterJob, "testing counter" 157 | sleep 1 158 | end 159 | end 160 | 161 | before do 162 | Resque.redis.del "testing" 163 | task 164 | sleep 1 165 | end 166 | 167 | it 'should run normal jobs and finalization job' do 168 | Resque.redis.get("testing counter").to_i.should == 3 169 | end 170 | end 171 | 172 | describe "Acceptance: Finalization always runs without block" do 173 | let(:task) do 174 | Resque::Plugins::MultiStepTask.create("testing") 175 | end 176 | 177 | before do 178 | Resque.redis.del "testing" 179 | task 180 | task.add_job MultiStepAcceptance::CounterJob, "testing counter" 181 | task.add_job MultiStepAcceptance::CounterJob, "testing counter" 182 | task.add_finalization_job MultiStepAcceptance::CounterJob, "testing counter" 183 | sleep 1 184 | task.finalizable! 185 | sleep 1 186 | end 187 | 188 | it 'should run normal jobs and finalization job' do 189 | Resque.redis.get("testing counter").to_i.should == 3 190 | end 191 | end 192 | 193 | describe "Acceptance: add job to multi step task via find" do 194 | let(:task) do 195 | Resque::Plugins::MultiStepTask.create("testing") do |task| 196 | task.add_job MultiStepAcceptance::CounterJob, "testing add job" 197 | task.add_job MultiStepAcceptance::WaitJob, 2 198 | end 199 | end 200 | 201 | before do 202 | Resque.redis.del "testing" 203 | task 204 | sleep 1 205 | task2 = Resque::Plugins::MultiStepTask.find(task.task_id) 206 | task2.add_job MultiStepAcceptance::CounterJob, "testing add job" 207 | sleep 1 208 | end 209 | 210 | it 'should run added jobs' do 211 | Resque.redis.get("testing add job").to_i.should == 2 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /spec/acceptance/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../spec_helper", File.dirname(__FILE__)) 2 | 3 | $LOAD_PATH << File.dirname(__FILE__) 4 | require 'acceptance_jobs' 5 | 6 | RSpec.configure do |c| 7 | c.before(:all) do 8 | puts '---------- Starting Resque Workers ----------' 9 | 3.times do |index| 10 | system "BACKGROUND=yes PIDFILE=resque#{index}.pid QUEUE=* NAMESPACE=resque-multi-step-task-testing INTERVAL=0.5 rake resque:work" 11 | end 12 | sleep 3 13 | end 14 | 15 | c.after(:all) do 16 | sleep 1 17 | puts '---------- Stopping Resque Workers ----------' 18 | 3.times do |index| 19 | pid = File.read("resque#{index}.pid").to_i 20 | File.delete("resque#{index}.pid") 21 | Process.kill('QUIT', pid) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/resque-multi-step_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper') 2 | 3 | describe "ResqueMultiStepTask" do 4 | it "defines MultiStepTask" do 5 | defined?(Resque::Plugins::MultiStepTask).should be_true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/resque/plugins/multi_step_task/finalization_job_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../spec_helper", File.dirname(__FILE__)) 2 | 3 | module Resque::Plugins 4 | class MultiStepTask 5 | describe FinalizationJob do 6 | let(:task){MultiStepTask.create("some-task")} 7 | 8 | before do 9 | task.finalizable! 10 | end 11 | 12 | it "queues next finalization job when done" do 13 | Resque::Job.should_receive(:create).with(anything, Resque::Plugins::MultiStepTask::FinalizationJob, task.task_id, 'TestJob', 42) 14 | 15 | task.add_finalization_job(TestJob, 42) 16 | 17 | FinalizationJob.perform(task.task_id, 'TestJob', 0) 18 | end 19 | 20 | it "cleans up on the last job" do 21 | task.should_receive(:nuke) 22 | MultiStepTask.stub!(:find).and_return(task) 23 | 24 | FinalizationJob.perform(task.task_id, 'TestJob', 0) 25 | end 26 | end 27 | end 28 | end 29 | 30 | module ::TestJob 31 | def self.perform(*args) 32 | # no op 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/resque/plugins/multi_step_task_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../spec_helper", File.dirname(__FILE__)) 2 | 3 | module Resque::Plugins 4 | describe MultiStepTask, "class" do 5 | it "allows creating new tasks" do 6 | MultiStepTask.create("brand-new-task").should be_kind_of(MultiStepTask) 7 | end 8 | 9 | it "allows creating new tasks with job specification block" do 10 | MultiStepTask.create("brand-new-task") do |task| 11 | task.add_job(TestJob) 12 | end.should be_kind_of(MultiStepTask) 13 | end 14 | 15 | it "uniquifies task queues when creating new task with same slug as and existing one" do 16 | a = MultiStepTask.create("some-task") 17 | b = MultiStepTask.create("some-task") 18 | 19 | a.queue_name.should_not == b.queue_name 20 | end 21 | 22 | it "allows finding existing tasks" do 23 | task_id = MultiStepTask.create("some-task").task_id 24 | 25 | MultiStepTask.find(task_id).should be_kind_of(MultiStepTask) 26 | MultiStepTask.find(task_id).task_id.should == task_id 27 | end 28 | 29 | it "raises exception on #find for non-existent task" do 30 | lambda{ 31 | MultiStepTask.find('nonexistent-task') 32 | }.should raise_error(MultiStepTask::NoSuchMultiStepTask) 33 | end 34 | 35 | it "allows mode to be set to :sync" do 36 | MultiStepTask.mode = :sync 37 | MultiStepTask.should be_synchronous 38 | end 39 | 40 | it "allows mode to be set to :async" do 41 | MultiStepTask.mode = :async 42 | MultiStepTask.should_not be_synchronous 43 | end 44 | end 45 | 46 | describe MultiStepTask do 47 | let(:task) {MultiStepTask.create("some-task")} 48 | 49 | it "allows jobs to be added to task" do 50 | lambda { 51 | task.add_job(TestJob) 52 | }.should_not raise_error 53 | end 54 | 55 | it "queues added job" do 56 | Resque::Job.should_receive(:create).with(task.queue_name, Resque::Plugins::MultiStepTask, task.task_id, 'TestJob') 57 | task.add_job(TestJob) 58 | end 59 | 60 | it "queues job when added to async task obtained via find" do 61 | Resque::Job.should_receive(:create).with(task.queue_name, Resque::Plugins::MultiStepTask, task.task_id, 'TestJob') 62 | Resque::Plugins::MultiStepTask.find(task.task_id).add_job(TestJob) 63 | end 64 | 65 | it "allows finalization jobs to be added" do 66 | task.add_finalization_job(TestJob) 67 | end 68 | 69 | it "allows itself to become finalizable" do 70 | task.finalizable! 71 | task.should be_finalizable 72 | end 73 | 74 | it "queues assure finalization job when it becomes finalizable" do 75 | Resque::Job.should_receive(:create).with(task.queue_name, ::Resque::Plugins::MultiStepTask::AssureFinalization, task.task_id) 76 | task.finalizable! 77 | end 78 | 79 | it "knows total job count" do 80 | task.add_job(TestJob) 81 | 82 | task.total_job_count.should == 1 83 | end 84 | 85 | it "includes finalization jobs in total job count" do 86 | task.add_job(TestJob) 87 | task.add_finalization_job(TestJob, "my", "args") 88 | 89 | task.total_job_count.should == 2 90 | end 91 | end 92 | 93 | describe MultiStepTask, "synchronous mode" do 94 | let(:task){MultiStepTask.create("some-task")} 95 | 96 | before do 97 | MultiStepTask.mode = :sync 98 | end 99 | 100 | after do 101 | MultiStepTask.mode = :async 102 | end 103 | 104 | it "runs job when added" do 105 | TestJob.should_receive(:perform).with("my", "args") 106 | task.add_job(TestJob, "my", "args") 107 | end 108 | 109 | it "runs finalization job when added" do 110 | TestJob.should_receive(:perform).with("my", "args") 111 | task.add_finalization_job(TestJob, "my", "args") 112 | task.finalizable! 113 | end 114 | 115 | it "runs finalization jobs last" do 116 | TestJob.should_receive(:perform).with("my", "args").ordered 117 | MyFinalJob.should_receive(:perform).with("final", "args").ordered 118 | 119 | task.add_finalization_job(MyFinalJob, "final", "args") 120 | task.add_job(TestJob, "my", "args") 121 | task.finalizable! 122 | end 123 | 124 | it "knows it has failed if a normal job raises an exception" do 125 | TestJob.should_receive(:perform).with("my", "args").ordered.and_raise('boo') 126 | MyFinalJob.should_not_receive(:perform) 127 | 128 | task.add_finalization_job(MyFinalJob, "final", "args") 129 | task.add_job(TestJob, "my", "args") rescue nil 130 | task.finalizable! 131 | 132 | task.should be_incomplete_because_of_errors 133 | end 134 | 135 | it "knows it has failed if a finalized job raises an exception" do 136 | MyFinalJob.should_receive(:perform).with("final", "args").ordered.and_raise('boo') 137 | 138 | task.add_finalization_job(MyFinalJob, "final", "args") 139 | 140 | lambda{ 141 | task.finalizable! 142 | }.should raise_error 143 | 144 | task.should be_unfinalized_because_of_errors 145 | end 146 | end 147 | 148 | describe MultiStepTask, "finalization" do 149 | let(:task){MultiStepTask.create("some-task")} 150 | 151 | before do 152 | task.finalizable! 153 | end 154 | 155 | it "queue finalization jobs" do 156 | Resque::Job.should_receive(:create).with(anything, Resque::Plugins::MultiStepTask::FinalizationJob, task.task_id, 'TestJob', 42) 157 | 158 | task.add_finalization_job(TestJob, 42) 159 | task.finalize! 160 | end 161 | 162 | it "initiates finalization at end of last job" do 163 | task.add_finalization_job(TestJob, 42) 164 | 165 | Resque::Job.should_receive(:create).with(anything, Resque::Plugins::MultiStepTask::FinalizationJob, task.task_id, 'TestJob', 42) 166 | Resque::Job.reserve(task.queue_name).perform 167 | end 168 | 169 | it "removes queue from resque" do 170 | task.add_job(TestJob) # creates queue 171 | MultiStepTask.perform(task.task_id, 'TestJob') # simulate runing final job 172 | 173 | Resque.queues.should_not include(task.task_id) 174 | end 175 | 176 | it "fails if finalization has already been run" do 177 | task.finalize! 178 | lambda{task.finalize!}.should raise_error(::Resque::Plugins::MultiStepTask::FinalizationAlreadyBegun) 179 | end 180 | 181 | it "fails if task is not yet finalizable" do 182 | task = MultiStepTask.create("some-task") 183 | lambda{task.finalize!}.should raise_error(::Resque::Plugins::MultiStepTask::NotReadyForFinalization) 184 | end 185 | 186 | it "fails if task has errors" do 187 | TestJob.should_receive(:perform).and_raise('boo') 188 | task = MultiStepTask.create("some-task") 189 | MultiStepTask.perform(task.task_id, 'TestJob') rescue nil 190 | 191 | lambda{task.finalize!}.should raise_error(::Resque::Plugins::MultiStepTask::NotReadyForFinalization) 192 | end 193 | 194 | end 195 | 196 | describe MultiStepTask, "performing job" do 197 | let(:task){MultiStepTask.create("some-task")} 198 | 199 | it "invokes specified job when #perform is called" do 200 | TestJob.should_receive(:perform).with(42, 'aaa') 201 | 202 | MultiStepTask.perform(task.task_id, 'TestJob', 42, 'aaa') 203 | end 204 | 205 | it "increments completed count on job success" do 206 | lambda{ 207 | MultiStepTask.perform(task.task_id, 'TestJob', 42, 'aaa') 208 | }.should change(task, :completed_count).by(1) 209 | end 210 | end 211 | 212 | describe MultiStepTask, "performing job that fails" do 213 | let(:task){MultiStepTask.create("some-task")} 214 | 215 | before do 216 | TestJob.should_receive(:perform).and_raise('boo') 217 | end 218 | 219 | it "increments failed count" do 220 | lambda{ 221 | MultiStepTask.perform(task.task_id, 'TestJob', 42, 'aaa') rescue nil 222 | }.should change(task, :failed_count).by(1) 223 | end 224 | 225 | it "does not increment completed count" do 226 | lambda{ 227 | MultiStepTask.perform(task.task_id, 'TestJob', 42, 'aaa') rescue nil 228 | }.should_not change(task, :completed_count).by(1) 229 | end 230 | 231 | it "bubbles raised exception job up to resque" do 232 | lambda{ 233 | MultiStepTask.perform(task.task_id, 'TestJob', 42, 'aaa') 234 | }.should raise_exception("boo") 235 | end 236 | 237 | it "knows it is incomplete because of failures" do 238 | task.increment_normal_job_count 239 | MultiStepTask.perform(task.task_id, 'TestJob', 42, 'aaa') rescue nil 240 | 241 | task.should be_incomplete_because_of_errors 242 | end 243 | 244 | it "knows it is complete when failures have occurred and have been retried successfully" do 245 | MultiStepTask.perform(task.task_id, 'TestJob', 42, 'aaa') rescue nil 246 | 247 | task.should_not be_incomplete_because_of_errors 248 | end 249 | end 250 | end 251 | 252 | module ::TestJob 253 | def self.perform(*args) 254 | # no op 255 | end 256 | end 257 | 258 | module ::MyFinalJob 259 | def self.perform(*args) 260 | # no op 261 | end 262 | end 263 | 264 | -------------------------------------------------------------------------------- /spec/spec.opts: -------------------------------------------------------------------------------- 1 | --format=specdoc 2 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 3 | require 'resque-multi-step' 4 | require 'pp' 5 | require 'rspec' 6 | 7 | RSpec.configure do |config| 8 | config.before do 9 | # Tests are jailed to a testing namespace and that space does 10 | # contains in left over data from previous runs. 11 | Resque.redis.namespace = "resque-multi-step-task-testing" 12 | Resque.redis.keys('*').each{|k| Resque.redis.del k} 13 | end 14 | end --------------------------------------------------------------------------------