[フレーム]
Last Updated: November 11, 2016
·
12.58K
· dsgh

Testing concurrency with rspec, the easy way

I recently found a nice article on solving concurrency issues, using the fork_break gem. Just what I needed, but it wasn't yet simple enough to reuse for all potential concurrency issues in the project. This should make it easier:

in rspec/support/concurrency.rb

def make_concurrent_calls(object, method, options={})
 options.reverse_merge!(:count => 2)
 processes = options[:count].times.map do |i|
 ForkBreak::Process.new do |breakpoints|
 # Add a breakpoint after invoking the method
 original_method = object.method(method)
 object.stub(method) do |*args|
 value = original_method.call(*args)
 breakpoints << method
 value
 end

 object.send(method)
 end
 end
 processes.each{ |process| process.run_until(method).wait }
 processes.each{ |process| process.finish.wait }
end

A model rspec test would then be:

context "unfrobbed thing" do
 let(:thing) { thing.make! }
 context "on concurrent calls to #frob" do
 before { make_concurrent_calls(thing, :frob) }
 it "should only create one frob" do
 # testing that the side effect is only executed once:
 expect(Frob.where(:thing_id => thing.id).count).to eq(1)
 end
 end
end

Some issues might require a solution at the controller level, it should be easy to adapt the test for those cases.

A lot of the concurrency issues happened on methods that were actually state_machine transitions. Here is a re-usable solution for those cases:

in config/initializers/lock_transition.rb

module LockTransition
 extend ActiveSupport::Concern

 def lock_transition(*args, &block)
 before_transition(*args) do |resource,transition|
 resource.lock!
 if block_given?
 yield
 else
 transition.from == resource.state
 end
 end
 end
end

class StateMachine::Machine
 include LockTransition
end

Then in the model:

class Thing < ActiveRecord::Base
 state_machine do
 # (states and events)
 lock_transition :from => :initial, :to => :frobbed
 after_transition :from => :initial, :to => :frobbed, :do => :create_frob
 end
 # (methods)
end

Just be careful to declare the lock_transition before any before_transition declaration containing side-effects which should be protected.

5 Responses
Add your response

For lock_transition, did you do any testing if transaction(isolation: serializable) was sufficient?

over 1 year ago ·

Nope, didn't even realise that the transaction isolation level is configurable.

over 1 year ago ·

Thanks for the post, super helpful. Can you tell me how the make_concurrent_calls method would look if the passed in method required arguments? For instance:
make_concurrent_calls(@user, :update(params), {})

over 1 year ago ·

@tommotaylor, no you would have to slightly change my example code, so that make_concurrent_calls can receive the method arguments separately (for example as an array argument), and then use them in the method call.

over 1 year ago ·

For anyone interested here is my implementation for methods that need arguments passed. I also used ruby keyword arguments, hard coded the number of concurrent processes, removed the options hash and added an ActiveRecord object check so it supports non ActiveRecord objects.

def make_concurrent_calls(object:, method:, method_args: nil)
 processes = 2.times.map do |i|
 ForkBreak::Process.new do |breakpoints|
 # Add a breakpoint after invoking the method
 original_method = object.method(method)
 object.reload if object.is_a?(ActiveRecord::Base)
 object.stub(method) do |*args|
 value = original_method.call(*args)
 breakpoints << method
 value
 end
 if method_args.present?
 args = method_args.collect{|k,v| v}
 object.send(method, *args)
 else
 object.send(method)
 end
 end
 end
 processes.each{ |process| process.run_until(method).wait }
 processes.each{ |process| process.finish.wait }
 end
over 1 year ago ·

AltStyle によって変換されたページ (->オリジナル) /