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