cwergq
Last Updated: November 11, 2016
·
5.673K
· dsgh
Bc4e82e924e728586395b97a47bb0945

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.

Say Thanks
Respond

5 Responses
Add your response

14686
91d00b2e1d76029cb9175053a846051b

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

over 1 year ago ·
14691
Bc4e82e924e728586395b97a47bb0945

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

over 1 year ago ·
28304

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 ·
28305
Bc4e82e924e728586395b97a47bb0945

@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 ·
28308

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 ·