L-exp Mobile

Specing Thread Safety In Rspec

This past week I was trying to add some load balancing and synchronization to a merb app that we wanted to try and run multiple instances of instead of just the one. The problem I ran in to was that when I started using mutexes to synchronize access to some variables shared between threads, I couldn't really think of a way to say in the spec that the operation "should be thread safe," or that the operation "should call some method under a lock." The best I could come up with was the following:

1 it "should perform the operation safely within a lock" do 2 Foo.mutex.should_receive(:synchronize).once.ordered.and_yield() 3 Foo.should_receive(:operation).once.ordered 4 5 Foo.is_it_safe? 6 end

In other words, expect that synchronize is called on my mutex or monitor and that it yields, then expect that my operation is called, and that they each happen in that order. Of course the problem is that while this spec passes with the following code...

1 def self.is_it_safe? 2 self.mutex.synchronize do 3 self.operation 4 end 5 end

...it also passes with this:

1 def self.is_it_safe? 2 self.mutex.synchronize do 3 end 4 self.operation 5 end

The obvious problem being that specifying the order of Mutex::synchronize (and what follows it) isn't the same as specifying thread safety. Rspec could have a way to spec thread safety, but maybe that's not general enough. What I think I may have been looking for instead is a way to spec the scope of a call, since saying that a call should be thread safe is just another way of saying that a call should be received within the scope of Mutex::synchronize. With that in mind, here're some definitions added to rspec's namespace:

1 module Spec 2 module Mocks 3 class ErrorGenerator 4 def raise_incorrect_scope_error(sym, expected, actual) 5 def gen_scope_string(list, sym) 6 return sym.to_s if list.empty? 7 return "#{list.shift}: { #{gen_scope_string(list, sym)} }" 8 end 9 actual_string = actual.empty? ? "no generated scope" : gen_scope_string(actual, sym) 10 __raise "#{intro} expected :#{sym} to be called within the scope of #{gen_scope_string(expected, sym)}, but received it within #{actual_string}" 11 end 12 end 13 14 class Space 15 def push_scope(scope) 16 scope_stack.push(scope) 17 end 18 19 def pop_scope 20 scope_stack.pop 21 end 22 23 def get_scope_stack 24 scope_stack 25 end 26 27 alias_method :original_reset_all, :reset_all 28 def reset_all 29 @scope_stack.clear unless @scope_stack.nil? 30 original_reset_all 31 end 32 33 private 34 35 def scope_stack 36 @scope_stack ||= [] 37 end 38 end 39 40 class BaseExpectation 41 alias_method :original_initialize, :initialize 42 def initialize(error_generator, expectation_ordering, expected_from, sym, method_block, expected_received_count=1, opts={}) 43 @expected_scope_stack = [] 44 @generated_scope = nil 45 original_initialize(error_generator, expectation_ordering, expected_from, sym, method_block, expected_received_count=1, opts={}) 46 end 47 48 def within_the_scope_of(obj) 49 @expected_scope_stack.unshift(obj) 50 self 51 end 52 53 def and_generate_scope(obj) 54 @generated_scope = obj 55 self 56 end 57 58 alias_method :original_invoke_with_yield, :invoke_with_yield 59 def invoke_with_yield(block) 60 begin 61 $rspec_mocks.push_scope(@generated_scope) unless @generated_scope.nil? 62 value = nil 63 value = original_invoke_with_yield(block) 64 rescue 65 raise 66 ensure 67 $rspec_mocks.pop_scope unless @generated_scope.nil? 68 value 69 end 70 end 71 72 alias_method :original_invoke, :invoke 73 def invoke(args, block) 74 unless @expected_scope_stack.empty? or $rspec_mocks.get_scope_stack == @expected_scope_stack 75 @error_generator.raise_incorrect_scope_error(@sym, @expected_scope_stack, $rspec_mocks.get_scope_stack) 76 end 77 original_invoke(args, block) 78 end 79 end 80 end 81 end

Admittedly, this is pretty hackish as is, and being just one way to accomplish it I'd like to find a better way in the future that's more in tune with rspec's organization. For now, an expectation can generate a scope using and_generate_scope(obj) which only has any effect when the expectation also yields a block, by using and_yield. A mock can then expect to be called within those generated scopes, in the order that you specify them in. Now the spec can tell the difference between a safe and unsafe call to Foo.operation:

1 it "should perform the operation safely within a lock" do 2 Foo.mutex.should_receive(:synchronize).once.ordered.and_yield().and_generate_scope('a') 3 Foo.should_receive(:operation).once.ordered.within_the_scope_of('a') 4 5 Foo.is_it_safe? 6 end

Here's a slightly different example, outside of the thread safety context:

1 class Bar 2 def self.a 3 yield 4 end 5 6 def self.b 7 yield 8 end 9 10 def self.c 11 puts "inner-most" 12 end 13 14 def self.try_me 15 a do 16 b do 17 c 18 end 19 end 20 end 21 end 22 23 describe "a class with operations that may or may not be thread-safe" do 24 it "should call c in the right scope" do 25 Bar.stub!(:a).and_yield().and_generate_scope('a') 26 Bar.stub!(:b).and_yield().and_generate_scope('b') 27 28 Bar.should_receive(:c).once.within_the_scope_of('b').within_the_scope_of('a') 29 30 Bar.try_me 31 end 32 end

This passes, but if you switch the order of called methods like so:

1 def self.try_me 2 b do 3 a do 4 c 5 end 6 end 7 end

...you get the following error:

Spec::Mocks::MockExpectationError in 'a class with operations that may or may not be thread-safe should call c in the right scope' Mock 'Class' expected :c to be called within the scope of a: { b: { c } }, but received it within b: { a: { c } }

Where this might be useful outside of my specific desire to spec thread safety has yet to be seen, but for now...I think "within_the_scope_of" might be a bit too much to type?



Options:   Save This | Share
Viewed 0 times
Published 3 months ago
By ceberz
From Resource Elc Tech Blog in lists:
Best Ruby on Rails Blogs

Menu

by Genís