record-cache 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. data/lib/record-cache.rb +1 -0
  2. data/lib/record_cache/active_record.rb +318 -0
  3. data/lib/record_cache/base.rb +136 -0
  4. data/lib/record_cache/dispatcher.rb +90 -0
  5. data/lib/record_cache/multi_read.rb +51 -0
  6. data/lib/record_cache/query.rb +85 -0
  7. data/lib/record_cache/statistics.rb +82 -0
  8. data/lib/record_cache/strategy/base.rb +154 -0
  9. data/lib/record_cache/strategy/id_cache.rb +93 -0
  10. data/lib/record_cache/strategy/index_cache.rb +122 -0
  11. data/lib/record_cache/strategy/request_cache.rb +49 -0
  12. data/lib/record_cache/test/resettable_version_store.rb +49 -0
  13. data/lib/record_cache/version.rb +5 -0
  14. data/lib/record_cache/version_store.rb +54 -0
  15. data/lib/record_cache.rb +11 -0
  16. data/spec/db/database.yml +6 -0
  17. data/spec/db/schema.rb +42 -0
  18. data/spec/db/seeds.rb +40 -0
  19. data/spec/initializers/record_cache.rb +14 -0
  20. data/spec/lib/dispatcher_spec.rb +86 -0
  21. data/spec/lib/multi_read_spec.rb +51 -0
  22. data/spec/lib/query_spec.rb +148 -0
  23. data/spec/lib/statistics_spec.rb +140 -0
  24. data/spec/lib/strategy/base_spec.rb +241 -0
  25. data/spec/lib/strategy/id_cache_spec.rb +168 -0
  26. data/spec/lib/strategy/index_cache_spec.rb +223 -0
  27. data/spec/lib/strategy/request_cache_spec.rb +85 -0
  28. data/spec/lib/version_store_spec.rb +104 -0
  29. data/spec/models/apple.rb +8 -0
  30. data/spec/models/banana.rb +8 -0
  31. data/spec/models/pear.rb +6 -0
  32. data/spec/models/person.rb +11 -0
  33. data/spec/models/store.rb +13 -0
  34. data/spec/spec_helper.rb +44 -0
  35. data/spec/support/after_commit.rb +71 -0
  36. data/spec/support/matchers/hit_cache_matcher.rb +53 -0
  37. data/spec/support/matchers/miss_cache_matcher.rb +53 -0
  38. data/spec/support/matchers/use_cache_matcher.rb +53 -0
  39. metadata +253 -0
@@ -0,0 +1,85 @@
1
+ require 'spec_helper'
2
+
3
+ describe RecordCache::Strategy::RequestCache do
4
+
5
+ it "should retrieve a record from the Request Cache" do
6
+ lambda{ Store.find(1) }.should miss_cache(Store)
7
+ lambda{ Store.find(1) }.should hit_cache(Store).on(:request_cache).times(1)
8
+ end
9
+
10
+ it "should retrieve the same record when the same query is used" do
11
+ @store_1 = Store.find(1)
12
+ @store_2 = Store.find(1)
13
+ @store_1.should == @store_2
14
+ @store_1.object_id.should == @store_2.object_id
15
+ end
16
+
17
+ context "logging" do
18
+ before(:each) do
19
+ Store.find(1)
20
+ end
21
+
22
+ it "should write hit to the debug log" do
23
+ mock(RecordCache::Base.logger).debug(/RequestCache hit for id=1\.L1|^(?!RequestCache)/).times(any_times)
24
+ Store.find(1)
25
+ end
26
+
27
+ it "should write miss to the debug log" do
28
+ mock(RecordCache::Base.logger).debug(/^RequestCache miss for id=2.L1|^(?!RequestCache)/).times(any_times)
29
+ Store.find(2)
30
+ end
31
+ end
32
+
33
+ context "record_change" do
34
+ before(:each) do
35
+ # cache query in request cache
36
+ @store1 = Store.find(1)
37
+ @store2 = Store.find(2)
38
+ end
39
+
40
+ it "should remove all records from the cache for a specific model when one record is destroyed" do
41
+ lambda{ Store.find(1) }.should hit_cache(Store).on(:request_cache).times(1)
42
+ lambda{ Store.find(2) }.should hit_cache(Store).on(:request_cache).times(1)
43
+ @store1.destroy
44
+ lambda{ Store.find(2) }.should miss_cache(Store).on(:request_cache).times(1)
45
+ end
46
+
47
+ it "should remove all records from the cache for a specific model when one record is updated" do
48
+ lambda{ Store.find(1) }.should hit_cache(Store).on(:request_cache).times(1)
49
+ lambda{ Store.find(2) }.should hit_cache(Store).on(:request_cache).times(1)
50
+ @store1.name = "Store E"
51
+ @store1.save!
52
+ lambda{ Store.find(1) }.should miss_cache(Store).on(:request_cache).times(1)
53
+ lambda{ Store.find(2) }.should miss_cache(Store).on(:request_cache).times(1)
54
+ end
55
+
56
+ it "should remove all records from the cache for a specific model when one record is created" do
57
+ lambda{ Store.find(1) }.should hit_cache(Store).on(:request_cache).times(1)
58
+ lambda{ Store.find(2) }.should hit_cache(Store).on(:request_cache).times(1)
59
+ Store.create!(:name => "New Apple Store")
60
+ lambda{ Store.find(1) }.should miss_cache(Store).on(:request_cache).times(1)
61
+ lambda{ Store.find(2) }.should miss_cache(Store).on(:request_cache).times(1)
62
+ end
63
+
64
+ end
65
+
66
+ context "invalidate" do
67
+ before(:each) do
68
+ # cache query in request cache
69
+ @store1 = Store.find(1)
70
+ @store2 = Store.find(2)
71
+ end
72
+
73
+ it "should remove all records from the cache when clear is explicitly called" do
74
+ lambda{ Store.find(1) }.should hit_cache(Store).on(:request_cache).times(1)
75
+ RecordCache::Strategy::RequestCache.clear
76
+ lambda{ Store.find(1) }.should miss_cache(Store).on(:request_cache).times(1)
77
+ end
78
+
79
+ it "should remove all records from the cache when invalidate is called" do
80
+ lambda{ Store.find(1) }.should hit_cache(Store).on(:request_cache).times(1)
81
+ Store.record_cache.invalidate(:request_cache, @store2)
82
+ lambda{ Store.find(1) }.should miss_cache(Store).on(:request_cache).times(1)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,104 @@
1
+ require 'spec_helper'
2
+
3
+ describe RecordCache::VersionStore do
4
+
5
+ before(:each) do
6
+ @version_store = RecordCache::Base.version_store
7
+ @version_store.store.write("key1", 1000)
8
+ @version_store.store.write("key2", 2000)
9
+ end
10
+
11
+ it "should only accept ActiveSupport cache stores" do
12
+ lambda { RecordCache::VersionStore.new(Object.new) }.should raise_error("Must be an ActiveSupport::Cache::Store")
13
+ end
14
+
15
+ context "current" do
16
+ it "should retrieve the current version" do
17
+ @version_store.current("key1").should == 1000
18
+ end
19
+
20
+ it "should retrieve nil for unknown keys" do
21
+ @version_store.current("unknown_key").should == nil
22
+ end
23
+ end
24
+
25
+ context "current_multi" do
26
+ it "should retrieve all versions" do
27
+ @version_store.current_multi({:id1 => "key1", :id2 => "key2"}).should == {:id1 => 1000, :id2 => 2000}
28
+ end
29
+
30
+ it "should return nil for unknown keys" do
31
+ @version_store.current_multi({:id1 => "key1", :key3 => "unknown_key"}).should == {:id1 => 1000, :key3 => nil}
32
+ end
33
+
34
+ it "should use read_multi on the underlying store" do
35
+ mock(@version_store.store).read_multi(/key[12]/, /key[12]/) { {"key1" => 5, "key2" => 6} }
36
+ @version_store.current_multi({:id1 => "key1", :id2 => "key2"}).should == {:id1 => 5, :id2 => 6}
37
+ end
38
+ end
39
+
40
+ context "renew" do
41
+ it "should renew the version" do
42
+ @version_store.current("key1").should == 1000
43
+ @version_store.renew("key1")
44
+ @version_store.current("key1").should_not == 1000
45
+ end
46
+
47
+ it "should renew the version for unknown keys" do
48
+ @version_store.current("unknown_key").should == nil
49
+ @version_store.renew("unknown_key")
50
+ @version_store.current("unknown_key").should_not == nil
51
+ end
52
+
53
+ it "should write to the debug log" do
54
+ mock(RecordCache::Base.logger).debug?{ true }
55
+ mock(RecordCache::Base.logger).debug(/Version Store: renew key1: nil => \d+/)
56
+ @version_store.renew("key1")
57
+ stub(RecordCache::Base.logger).debug?{ false } # to prevent the ResettableVersionStore from logging in +after(:each)+
58
+ end
59
+ end
60
+
61
+ context "increment" do
62
+ it "should increment the version" do
63
+ @version_store.current("key1").should == 1000
64
+ @version_store.increment("key1")
65
+ @version_store.current("key1").should == 1001
66
+ end
67
+
68
+ it "should renew the version on increment for unknown keys" do
69
+ # do not use unknown_key as the version store retains the value after this spec
70
+ @version_store.current("unknown_key").should == nil
71
+ @version_store.renew("unknown_key")
72
+ @version_store.current("unknown_key").should_not == nil
73
+ end
74
+
75
+ it "should write to the debug log" do
76
+ mock(RecordCache::Base.logger).debug?{ true }
77
+ mock(RecordCache::Base.logger).debug("Version Store: incremented key1: 1000 => 1001")
78
+ @version_store.increment("key1")
79
+ stub(RecordCache::Base.logger).debug?{ false } # to prevent the ResettableVersionStore from logging in +after(:each)+
80
+ end
81
+ end
82
+
83
+ context "delete" do
84
+ it "should delete the version" do
85
+ @version_store.current("key1").should == 1000
86
+ @version_store.delete("key1").should == true
87
+ @version_store.current("key1").should == nil
88
+ end
89
+
90
+ it "should not raise an error when deleting the version for unknown keys" do
91
+ @version_store.current("unknown_key").should == nil
92
+ @version_store.delete("unknown_key").should == false
93
+ @version_store.current("unknown_key").should == nil
94
+ end
95
+
96
+ it "should write to the debug log" do
97
+ mock(RecordCache::Base.logger).debug?{ true }
98
+ mock(RecordCache::Base.logger).debug("Version Store: deleted key1")
99
+ @version_store.delete("key1")
100
+ stub(RecordCache::Base.logger).debug?{ false } # to prevent the ResettableVersionStore from logging in +after(:each)+
101
+ end
102
+ end
103
+
104
+ end
@@ -0,0 +1,8 @@
1
+ class Apple < ActiveRecord::Base
2
+
3
+ cache_records :store => :shared, :key => "apl", :index => [:store_id, :person_id]
4
+
5
+ belongs_to :store
6
+ belongs_to :person
7
+
8
+ end
@@ -0,0 +1,8 @@
1
+ class Banana < ActiveRecord::Base
2
+
3
+ cache_records :store => :local, :index => [:person_id]
4
+
5
+ belongs_to :store
6
+ belongs_to :person
7
+
8
+ end
@@ -0,0 +1,6 @@
1
+ class Pear < ActiveRecord::Base
2
+
3
+ belongs_to :store
4
+ belongs_to :person
5
+
6
+ end
@@ -0,0 +1,11 @@
1
+ class Person < ActiveRecord::Base
2
+
3
+ cache_records :store => :shared, :key => "per"
4
+
5
+ has_many :apples # cached with index on person_id
6
+ has_many :bananas # cached with index on person_id
7
+ has_many :pears # not cached
8
+
9
+ has_and_belongs_to_many :stores
10
+
11
+ end
@@ -0,0 +1,13 @@
1
+ class Store < ActiveRecord::Base
2
+
3
+ cache_records :store => :local, :key => "st", :request_cache => true
4
+
5
+ belongs_to :owner, :class_name => "Person"
6
+
7
+ has_many :apples, :autosave => true # cached with index on store
8
+ has_many :bananas # cached without index on store
9
+ has_many :pears # not cached
10
+
11
+ has_and_belongs_to_many :customers, :class_name => "Person"
12
+
13
+ end
@@ -0,0 +1,44 @@
1
+ dir = File.dirname(__FILE__)
2
+ $LOAD_PATH.unshift dir + "/../lib"
3
+ $LOAD_PATH.unshift dir
4
+
5
+ require "rubygems"
6
+ require "test/unit"
7
+ require "rspec"
8
+ require 'rr'
9
+ require 'database_cleaner'
10
+ require "logger"
11
+ require "record_cache"
12
+ require "record_cache/test/resettable_version_store"
13
+
14
+ # spec support files
15
+ Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each {|f| require f}
16
+
17
+ # logging
18
+ Dir.mkdir(dir + "/log") unless File.exists?(dir + "/log")
19
+ ActiveRecord::Base.logger = Logger.new(dir + "/log/debug.log")
20
+ # ActiveRecord::Base.logger = Logger.new(STDOUT)
21
+
22
+ # SQL Lite
23
+ ActiveRecord::Base.configurations = YAML::load(IO.read(dir + "/db/database.yml"))
24
+ ActiveRecord::Base.establish_connection("sqlite3")
25
+
26
+ # Initializers + Model + Data
27
+ load(dir + "/initializers/record_cache.rb")
28
+ load(dir + "/db/schema.rb")
29
+ Dir["#{dir}/models/*.rb"].each {|f| load(f) }
30
+ load(dir + "/db/seeds.rb")
31
+
32
+ # Clear cache after each test
33
+ RSpec.configure do |config|
34
+ config.mock_with :rr
35
+
36
+ config.before(:each) do
37
+ DatabaseCleaner.start
38
+ end
39
+
40
+ config.after(:each) do
41
+ DatabaseCleaner.clean
42
+ RecordCache::Base.version_store.reset!
43
+ end
44
+ end
@@ -0,0 +1,71 @@
1
+ # @see http://outofti.me/post/4777884779/test-after-commit-hooks-with-transactional-fixtures
2
+ module ActiveRecord
3
+ module ConnectionAdapters
4
+ module DatabaseStatements
5
+ #
6
+ # Run the normal transaction method; when it's done, check to see if there
7
+ # is exactly one open transaction. If so, that's the transactional
8
+ # fixtures transaction; from the model's standpoint, the completed
9
+ # transaction is the real deal. Send commit callbacks to models.
10
+ #
11
+ # If the transaction block raises a Rollback, we need to know, so we don't
12
+ # call the commit hooks. Other exceptions don't need to be explicitly
13
+ # accounted for since they will raise uncaught through this method and
14
+ # prevent the code after the hook from running.
15
+ #
16
+ def transaction_with_transactional_fixtures(options = {}, &block)
17
+ rolled_back = false
18
+
19
+ transaction_without_transactional_fixtures do
20
+ begin
21
+ yield
22
+ rescue Exception => exception
23
+ if exception.is_a?(ActiveRecord::Rollback)
24
+ rolled_back = true
25
+ else
26
+ puts "Exception in aftercommit: #{exception}"
27
+ puts exception.backtrace
28
+ end
29
+ raise exception
30
+ end
31
+ end
32
+
33
+ if !rolled_back && open_transactions == 1
34
+ commit_transaction_records(false)
35
+ end
36
+ end
37
+ alias_method_chain :transaction, :transactional_fixtures
38
+
39
+ #
40
+ # The @_current_transaction_records is an stack of arrays, each one
41
+ # containing the records associated with the corresponding transaction
42
+ # in the transaction stack. This is used by the
43
+ # `rollback_transaction_records` method (to only send a rollback hook to
44
+ # models attached to the transaction being rolled back) but is usually
45
+ # ignored by the `commit_transaction_records` method. Here we
46
+ # monkey-patch it to temporarily replace the array with only the records
47
+ # for the top-of-stack transaction, so the real
48
+ # `commit_transaction_records` method only sends callbacks to those.
49
+ #
50
+ def commit_transaction_records_with_transactional_fixtures(commit = true)
51
+ unless commit
52
+ real_current_transaction_records = @_current_transaction_records
53
+ @_current_transaction_records = @_current_transaction_records.pop
54
+ end
55
+
56
+ begin
57
+ @_current_transaction_records ||= []
58
+ commit_transaction_records_without_transactional_fixtures
59
+ rescue Exception => exception
60
+ puts "Error in aftercommit: #{exception}"
61
+ puts exception.backtrace
62
+ ensure
63
+ unless commit
64
+ @_current_transaction_records = real_current_transaction_records
65
+ end
66
+ end
67
+ end
68
+ alias_method_chain :commit_transaction_records, :transactional_fixtures
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,53 @@
1
+ # Examples:
2
+ # 1) lambda{ Person.find(22) }.should hit_cache(Person)
3
+ # _should have at least one hit in any of the cache strategies for the Person model_
4
+ #
5
+ # 2) lambda{ Person.find(22) }.should hit_cache(Person).on(:id)
6
+ # _should have at least one hit in the ID cache strategy for the Person model_
7
+ #
8
+ # 3) lambda{ Person.find_by_ids(22, 23, 24) }.should hit_cache(Person).on(:id).times(2)
9
+ # _should have exactly two hits in the :id cache strategy for the Person model_
10
+ #
11
+ # 4) lambda{ Person.find_by_ids(22, 23, 24) }.should hit_cache(Person).times(3)
12
+ # _should have exactly three hits in any of the cache strategies for the Person model_
13
+ RSpec::Matchers.define :hit_cache do |model|
14
+
15
+ chain :on do |strategy|
16
+ @strategy = strategy
17
+ end
18
+
19
+ chain :times do |nr_of_hits|
20
+ @expected_nr_of_hits = nr_of_hits
21
+ end
22
+
23
+ match do |proc|
24
+ # reset statistics for the given model and start counting
25
+ RecordCache::Statistics.reset!(model)
26
+ RecordCache::Statistics.start
27
+ # call the given proc
28
+ proc.call
29
+ # collect statistics for the model
30
+ @stats = RecordCache::Statistics.find(model)
31
+ # check the nr of hits
32
+ @nr_of_hits = @strategy ? @stats[@strategy].hits : @stats.values.map{ |s| s.hits }.sum
33
+ # test nr of hits
34
+ @expected_nr_of_hits ? @nr_of_hits == @expected_nr_of_hits : @nr_of_hits > 0
35
+ end
36
+
37
+ failure_message_for_should do |proc|
38
+ prepare_message
39
+ "Expected #{@strategy_msg} for #{model.name} to be hit #{@times_msg}, but found #{@nr_of_hits}: #{@statistics_msg}"
40
+ end
41
+
42
+ failure_message_for_should_not do |proc|
43
+ prepare_message
44
+ "Expected #{@strategy_msg} for #{model.name} not to be hit #{@times_msg}, but found #{@nr_of_hits}: #{@statistics_msg}"
45
+ end
46
+
47
+ def prepare_message
48
+ @strategy_msg = @strategy ? "the #{@strategy} cache" : "any of the caches"
49
+ @times_msg = @expected_nr_of_hits ? (@expected_nr_of_hits == 1 ? "exactly once" : "exactly #{@expected_nr_of_hits} times") : "at least once"
50
+ @statistics_msg = @stats.map{|strategy, s| "#{strategy} => #{s.inspect}" }.join(", ")
51
+ end
52
+
53
+ end
@@ -0,0 +1,53 @@
1
+ # Examples:
2
+ # 1) lambda{ Person.find(22) }.should miss_cache(Person)
3
+ # _should have at least one miss in any of the cache strategies for the Person model_
4
+ #
5
+ # 2) lambda{ Person.find(22) }.should miss_cache(Person).on(:id)
6
+ # _should have at least one miss for the ID cache strategy for the Person model_
7
+ #
8
+ # 3) lambda{ Person.find_by_ids(22, 23, 24) }.should miss_cache(Person).on(:id).times(2)
9
+ # _should have exactly two misses in the :id cache strategy for the Person model_
10
+ #
11
+ # 4) lambda{ Person.find_by_ids(22, 23, 24) }.should miss_cache(Person).times(3)
12
+ # _should have exactly three misses in any of the cache strategies for the Person model_
13
+ RSpec::Matchers.define :miss_cache do |model|
14
+
15
+ chain :on do |strategy|
16
+ @strategy = strategy
17
+ end
18
+
19
+ chain :times do |nr_of_misses|
20
+ @expected_nr_of_misses = nr_of_misses
21
+ end
22
+
23
+ match do |proc|
24
+ # reset statistics for the given model and start counting
25
+ RecordCache::Statistics.reset!(model)
26
+ RecordCache::Statistics.start
27
+ # call the given proc
28
+ proc.call
29
+ # collect statistics for the model
30
+ @stats = RecordCache::Statistics.find(model)
31
+ # check the nr of misses
32
+ @nr_of_misses = @strategy ? @stats[@strategy].misses : @stats.values.map{ |s| s.misses }.sum
33
+ # test nr of misses
34
+ @expected_nr_of_misses ? @nr_of_misses == @expected_nr_of_misses : @nr_of_misses > 0
35
+ end
36
+
37
+ failure_message_for_should do |proc|
38
+ prepare_message
39
+ "Expected #{@strategy_msg} for #{model.name} to be missed #{@times_msg}, but found #{@nr_of_misses}: #{@statistics_msg}"
40
+ end
41
+
42
+ failure_message_for_should_not do |proc|
43
+ prepare_message
44
+ "Expected #{@strategy_msg} for #{model.name} not to be missed #{@times_msg}, but found #{@nr_of_misses}: #{@statistics_msg}"
45
+ end
46
+
47
+ def prepare_message
48
+ @strategy_msg = @strategy ? "the #{@strategy} cache" : "any of the caches"
49
+ @times_msg = @expected_nr_of_misses ? (@expected_nr_of_misses == 1 ? "exactly once" : "exactly #{@expected_nr_of_misses} times") : "at least once"
50
+ @statistics_msg = @stats.map{|strategy, s| "#{strategy} => #{s.inspect}" }.join(", ")
51
+ end
52
+
53
+ end
@@ -0,0 +1,53 @@
1
+ # Examples:
2
+ # 1) lambda{ Person.find(22) }.should use_cache(Person)
3
+ # _should perform at least one call (hit/miss) to any of the cache strategies for the Person model_
4
+ #
5
+ # 2) lambda{ Person.find(22) }.should use_cache(Person).on(:id)
6
+ # _should perform at least one call (hit/miss) to the ID cache strategy for the Person model_
7
+ #
8
+ # 3) lambda{ Person.find_by_ids(22, 23, 24) }.should use_cache(Person).on(:id).times(2)
9
+ # _should perform exactly two calls (hit/miss) to the :id cache strategy for the Person model_
10
+ #
11
+ # 4) lambda{ Person.find_by_ids(22, 23, 24) }.should use_cache(Person).times(3)
12
+ # _should perform exactly three calls (hit/miss) to any of the cache strategies for the Person model_
13
+ RSpec::Matchers.define :use_cache do |model|
14
+
15
+ chain :on do |strategy|
16
+ @strategy = strategy
17
+ end
18
+
19
+ chain :times do |nr_of_calls|
20
+ @expected_nr_of_calls = nr_of_calls
21
+ end
22
+
23
+ match do |proc|
24
+ # reset statistics for the given model and start counting
25
+ RecordCache::Statistics.reset!(model)
26
+ RecordCache::Statistics.start
27
+ # call the given proc
28
+ proc.call
29
+ # collect statistics for the model
30
+ @stats = RecordCache::Statistics.find(model)
31
+ # check the nr of calls
32
+ @nr_of_calls = @strategy ? @stats[@strategy].calls : @stats.values.map{ |s| s.calls }.sum
33
+ # test nr of calls
34
+ @expected_nr_of_calls ? @nr_of_calls == @expected_nr_of_calls : @nr_of_calls > 0
35
+ end
36
+
37
+ failure_message_for_should do |proc|
38
+ prepare_message
39
+ "Expected #{@strategy_msg} for #{model.name} to be called #{@times_msg}, but found #{@nr_of_calls}: #{@statistics_msg}"
40
+ end
41
+
42
+ failure_message_for_should_not do |proc|
43
+ prepare_message
44
+ "Expected #{@strategy_msg} for #{model.name} not to be called #{@times_msg}, but found #{@nr_of_calls}: #{@statistics_msg}"
45
+ end
46
+
47
+ def prepare_message
48
+ @strategy_msg = @strategy ? "the #{@strategy} cache" : "any of the caches"
49
+ @times_msg = @expected_nr_of_calls ? (@expected_nr_of_calls == 1 ? "exactly once" : "exactly #{@expected_nr_of_calls} times") : "at least once"
50
+ @statistics_msg = @stats.map{|strategy, s| "#{strategy} => #{s.inspect}" }.join(", ")
51
+ end
52
+
53
+ end