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.
Written by Duarte Henriques
Related protips
5 Responses
For lock_transition, did you do any testing if transaction(isolation: serializable) was sufficient?
Nope, didn't even realise that the transaction isolation level is configurable.
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), {})
@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.
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