450 lines
14 KiB
Ruby
450 lines
14 KiB
Ruby
require 'thread'
|
|
require 'thread_safe'
|
|
require File.join(File.dirname(__FILE__), "test_helper")
|
|
|
|
Thread.abort_on_exception = true
|
|
|
|
class TestCacheTorture < Minitest::Test # this is not run unless RUBY_VERSION =~ /1\.8/ || ENV['TRAVIS'] (see the end of the file)
|
|
THREAD_COUNT = 40
|
|
KEY_COUNT = (((2**13) - 2) * 0.75).to_i # get close to the doubling cliff
|
|
LOW_KEY_COUNT = (((2**8 ) - 2) * 0.75).to_i # get close to the doubling cliff
|
|
|
|
INITIAL_VALUE_CACHE_SETUP = lambda do |options, keys|
|
|
cache = ThreadSafe::Cache.new
|
|
initial_value = options[:initial_value] || 0
|
|
keys.each {|key| cache[key] = initial_value}
|
|
cache
|
|
end
|
|
ZERO_VALUE_CACHE_SETUP = lambda do |options, keys|
|
|
INITIAL_VALUE_CACHE_SETUP.call(options.merge(:initial_value => 0), keys)
|
|
end
|
|
|
|
DEFAULTS = {
|
|
:key_count => KEY_COUNT,
|
|
:thread_count => THREAD_COUNT,
|
|
:loop_count => 1,
|
|
:prelude => '',
|
|
:cache_setup => lambda {|options, keys| ThreadSafe::Cache.new}
|
|
}
|
|
|
|
LOW_KEY_COUNT_OPTIONS = {:loop_count => 150, :key_count => LOW_KEY_COUNT}
|
|
SINGLE_KEY_COUNT_OPTIONS = {:loop_count => 100_000, :key_count => 1}
|
|
|
|
def test_concurrency
|
|
code = <<-RUBY_EVAL
|
|
cache[key]
|
|
cache[key] = key
|
|
cache[key]
|
|
cache.delete(key)
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code)
|
|
end
|
|
|
|
def test_put_if_absent
|
|
do_thread_loop(__method__, 'acc += 1 unless cache.put_if_absent(key, key)', :key_count => 100_000) do |result, cache, options, keys|
|
|
assert_standard_accumulator_test_result(result, cache, options, keys)
|
|
end
|
|
end
|
|
|
|
def test_compute_if_absent
|
|
code = 'cache.compute_if_absent(key) { acc += 1; key }'
|
|
do_thread_loop(__method__, code) do |result, cache, options, keys|
|
|
assert_standard_accumulator_test_result(result, cache, options, keys)
|
|
end
|
|
end
|
|
|
|
def test_compute_put_if_absent
|
|
code = <<-RUBY_EVAL
|
|
if key.even?
|
|
cache.compute_if_absent(key) { acc += 1; key }
|
|
else
|
|
acc += 1 unless cache.put_if_absent(key, key)
|
|
end
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code) do |result, cache, options, keys|
|
|
assert_standard_accumulator_test_result(result, cache, options, keys)
|
|
end
|
|
end
|
|
|
|
def test_compute_if_absent_and_present
|
|
compute_if_absent_and_present
|
|
compute_if_absent_and_present(LOW_KEY_COUNT_OPTIONS)
|
|
compute_if_absent_and_present(SINGLE_KEY_COUNT_OPTIONS)
|
|
end
|
|
|
|
def test_add_remove_to_zero
|
|
add_remove_to_zero
|
|
add_remove_to_zero(LOW_KEY_COUNT_OPTIONS)
|
|
add_remove_to_zero(SINGLE_KEY_COUNT_OPTIONS)
|
|
end
|
|
|
|
def test_add_remove_to_zero_via_merge_pair
|
|
add_remove_to_zero_via_merge_pair
|
|
add_remove_to_zero_via_merge_pair(LOW_KEY_COUNT_OPTIONS)
|
|
add_remove_to_zero_via_merge_pair(SINGLE_KEY_COUNT_OPTIONS)
|
|
end
|
|
|
|
def test_add_remove
|
|
add_remove
|
|
add_remove(LOW_KEY_COUNT_OPTIONS)
|
|
add_remove(SINGLE_KEY_COUNT_OPTIONS)
|
|
end
|
|
|
|
def test_add_remove_via_compute
|
|
add_remove_via_compute
|
|
add_remove_via_compute(LOW_KEY_COUNT_OPTIONS)
|
|
add_remove_via_compute(SINGLE_KEY_COUNT_OPTIONS)
|
|
end
|
|
|
|
def add_remove_via_compute_if_absent_present
|
|
add_remove_via_compute_if_absent_present
|
|
add_remove_via_compute_if_absent_present(LOW_KEY_COUNT_OPTIONS)
|
|
add_remove_via_compute_if_absent_present(SINGLE_KEY_COUNT_OPTIONS)
|
|
end
|
|
|
|
def test_add_remove_indiscriminate
|
|
add_remove_indiscriminate
|
|
add_remove_indiscriminate(LOW_KEY_COUNT_OPTIONS)
|
|
add_remove_indiscriminate(SINGLE_KEY_COUNT_OPTIONS)
|
|
end
|
|
|
|
def test_count_up
|
|
count_up
|
|
count_up(LOW_KEY_COUNT_OPTIONS)
|
|
count_up(SINGLE_KEY_COUNT_OPTIONS)
|
|
end
|
|
|
|
def test_count_up_via_compute
|
|
count_up_via_compute
|
|
count_up_via_compute(LOW_KEY_COUNT_OPTIONS)
|
|
count_up_via_compute(SINGLE_KEY_COUNT_OPTIONS)
|
|
end
|
|
|
|
def test_count_up_via_merge_pair
|
|
count_up_via_merge_pair
|
|
count_up_via_merge_pair(LOW_KEY_COUNT_OPTIONS)
|
|
count_up_via_merge_pair(SINGLE_KEY_COUNT_OPTIONS)
|
|
end
|
|
|
|
def test_count_race
|
|
prelude = 'change = (rand(2) == 1) ? 1 : -1'
|
|
code = <<-RUBY_EVAL
|
|
v = cache[key]
|
|
acc += change if cache.replace_pair(key, v, v + change)
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, :loop_count => 5, :prelude => prelude, :cache_setup => ZERO_VALUE_CACHE_SETUP) do |result, cache, options, keys|
|
|
result_sum = sum(result)
|
|
assert_equal(sum(keys.map {|key| cache[key]}), result_sum)
|
|
assert_equal(sum(cache.values), result_sum)
|
|
assert_equal(options[:key_count], cache.size)
|
|
end
|
|
end
|
|
|
|
def test_get_and_set_new
|
|
code = 'acc += 1 unless cache.get_and_set(key, key)'
|
|
do_thread_loop(__method__, code) do |result, cache, options, keys|
|
|
assert_standard_accumulator_test_result(result, cache, options, keys)
|
|
end
|
|
end
|
|
|
|
def test_get_and_set_existing
|
|
code = 'acc += 1 if cache.get_and_set(key, key) == -1'
|
|
do_thread_loop(__method__, code, :cache_setup => INITIAL_VALUE_CACHE_SETUP, :initial_value => -1) do |result, cache, options, keys|
|
|
assert_standard_accumulator_test_result(result, cache, options, keys)
|
|
end
|
|
end
|
|
|
|
private
|
|
def compute_if_absent_and_present(opts = {})
|
|
prelude = 'on_present = rand(2) == 1'
|
|
code = <<-RUBY_EVAL
|
|
if on_present
|
|
cache.compute_if_present(key) {|old_value| acc += 1; old_value + 1}
|
|
else
|
|
cache.compute_if_absent(key) { acc += 1; 1 }
|
|
end
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, {:loop_count => 5, :prelude => prelude}.merge(opts)) do |result, cache, options, keys|
|
|
stored_sum = 0
|
|
stored_key_count = 0
|
|
keys.each do |k|
|
|
if value = cache[k]
|
|
stored_sum += value
|
|
stored_key_count += 1
|
|
end
|
|
end
|
|
assert_equal(stored_sum, sum(result))
|
|
assert_equal(stored_key_count, cache.size)
|
|
end
|
|
end
|
|
|
|
def add_remove(opts = {})
|
|
prelude = 'do_add = rand(2) == 1'
|
|
code = <<-RUBY_EVAL
|
|
if do_add
|
|
acc += 1 unless cache.put_if_absent(key, key)
|
|
else
|
|
acc -= 1 if cache.delete_pair(key, key)
|
|
end
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, {:loop_count => 5, :prelude => prelude}.merge(opts)) do |result, cache, options, keys|
|
|
assert_all_key_mappings_exist(cache, keys, false)
|
|
assert_equal(cache.size, sum(result))
|
|
end
|
|
end
|
|
|
|
def add_remove_via_compute(opts = {})
|
|
prelude = 'do_add = rand(2) == 1'
|
|
code = <<-RUBY_EVAL
|
|
cache.compute(key) do |old_value|
|
|
if do_add
|
|
acc += 1 unless old_value
|
|
key
|
|
else
|
|
acc -= 1 if old_value
|
|
nil
|
|
end
|
|
end
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, {:loop_count => 5, :prelude => prelude}.merge(opts)) do |result, cache, options, keys|
|
|
assert_all_key_mappings_exist(cache, keys, false)
|
|
assert_equal(cache.size, sum(result))
|
|
end
|
|
end
|
|
|
|
def add_remove_via_compute_if_absent_present(opts = {})
|
|
prelude = 'do_add = rand(2) == 1'
|
|
code = <<-RUBY_EVAL
|
|
if do_add
|
|
cache.compute_if_absent(key) { acc += 1; key }
|
|
else
|
|
cache.compute_if_present(key) { acc -= 1; nil }
|
|
end
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, {:loop_count => 5, :prelude => prelude}.merge(opts)) do |result, cache, options, keys|
|
|
assert_all_key_mappings_exist(cache, keys, false)
|
|
assert_equal(cache.size, sum(result))
|
|
end
|
|
end
|
|
|
|
def add_remove_indiscriminate(opts = {})
|
|
prelude = 'do_add = rand(2) == 1'
|
|
code = <<-RUBY_EVAL
|
|
if do_add
|
|
acc += 1 unless cache.put_if_absent(key, key)
|
|
else
|
|
acc -= 1 if cache.delete(key)
|
|
end
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, {:loop_count => 5, :prelude => prelude}.merge(opts)) do |result, cache, options, keys|
|
|
assert_all_key_mappings_exist(cache, keys, false)
|
|
assert_equal(cache.size, sum(result))
|
|
end
|
|
end
|
|
|
|
def count_up(opts = {})
|
|
code = <<-RUBY_EVAL
|
|
v = cache[key]
|
|
acc += 1 if cache.replace_pair(key, v, v + 1)
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, {:loop_count => 5, :cache_setup => ZERO_VALUE_CACHE_SETUP}.merge(opts)) do |result, cache, options, keys|
|
|
assert_count_up(result, cache, options, keys)
|
|
end
|
|
end
|
|
|
|
def count_up_via_compute(opts = {})
|
|
code = <<-RUBY_EVAL
|
|
cache.compute(key) do |old_value|
|
|
acc += 1
|
|
old_value ? old_value + 1 : 1
|
|
end
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, {:loop_count => 5}.merge(opts)) do |result, cache, options, keys|
|
|
assert_count_up(result, cache, options, keys)
|
|
result.inject(nil) do |previous_value, next_value| # since compute guarantees atomicity all count ups should be equal
|
|
assert_equal previous_value, next_value if previous_value
|
|
next_value
|
|
end
|
|
end
|
|
end
|
|
|
|
def count_up_via_merge_pair(opts = {})
|
|
code = <<-RUBY_EVAL
|
|
cache.merge_pair(key, 1) {|old_value| old_value + 1}
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, {:loop_count => 5}.merge(opts)) do |result, cache, options, keys|
|
|
all_match = true
|
|
expected_value = options[:loop_count] * options[:thread_count]
|
|
keys.each do |key|
|
|
if expected_value != (value = cache[key])
|
|
all_match = false
|
|
break
|
|
end
|
|
end
|
|
assert all_match
|
|
end
|
|
end
|
|
|
|
def add_remove_to_zero(opts = {})
|
|
code = <<-RUBY_EVAL
|
|
acc += 1 unless cache.put_if_absent(key, key)
|
|
acc -= 1 if cache.delete_pair(key, key)
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, {:loop_count => 5}.merge(opts)) do |result, cache, options, keys|
|
|
assert_all_key_mappings_exist(cache, keys, false)
|
|
assert_equal(cache.size, sum(result))
|
|
end
|
|
end
|
|
|
|
def add_remove_to_zero_via_merge_pair(opts = {})
|
|
code = <<-RUBY_EVAL
|
|
acc += (cache.merge_pair(key, key) {}) ? 1 : -1
|
|
RUBY_EVAL
|
|
do_thread_loop(__method__, code, {:loop_count => 5}.merge(opts)) do |result, cache, options, keys|
|
|
assert_all_key_mappings_exist(cache, keys, false)
|
|
assert_equal(cache.size, sum(result))
|
|
end
|
|
end
|
|
|
|
def do_thread_loop(name, code, options = {}, &block)
|
|
options = DEFAULTS.merge(options)
|
|
meth = define_loop name, code, options[:prelude]
|
|
keys = to_keys_array(options[:key_count])
|
|
run_thread_loop(meth, keys, options, &block)
|
|
|
|
if options[:key_count] > 1
|
|
options[:key_count] = (options[:key_count] / 40).to_i
|
|
keys = to_hash_collision_keys_array(options[:key_count])
|
|
run_thread_loop(meth, keys, options.merge(:loop_count => (options[:loop_count] * 5)), &block)
|
|
end
|
|
end
|
|
|
|
def run_thread_loop(meth, keys, options)
|
|
cache = options[:cache_setup].call(options, keys)
|
|
barrier = ThreadSafe::Test::Barrier.new(options[:thread_count])
|
|
result = (1..options[:thread_count]).map do
|
|
Thread.new do
|
|
setup_sync_and_start_loop(meth, cache, keys, barrier, options[:loop_count])
|
|
end
|
|
end.map(&:value)
|
|
yield result, cache, options, keys if block_given?
|
|
end
|
|
|
|
def setup_sync_and_start_loop(meth, cache, keys, barrier, loop_count)
|
|
my_keys = keys.shuffle
|
|
barrier.await
|
|
if my_keys.size == 1
|
|
key = my_keys.first
|
|
send("#{meth}_single_key", cache, key, loop_count)
|
|
else
|
|
send("#{meth}_multiple_keys", cache, my_keys, loop_count)
|
|
end
|
|
end
|
|
|
|
def define_loop(name, body, prelude)
|
|
inner_meth_name = :"_#{name}_loop_inner"
|
|
outer_meth_name = :"_#{name}_loop_outer"
|
|
# looping is splitted into the "loop methods" to trigger the JIT
|
|
self.class.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
|
def #{inner_meth_name}_multiple_keys(cache, keys, i, length, acc)
|
|
#{prelude}
|
|
target = i + length
|
|
while i < target
|
|
key = keys[i]
|
|
#{body}
|
|
i += 1
|
|
end
|
|
acc
|
|
end unless method_defined?(:#{inner_meth_name}_multiple_keys)
|
|
RUBY_EVAL
|
|
|
|
self.class.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
|
def #{inner_meth_name}_single_key(cache, key, i, length, acc)
|
|
#{prelude}
|
|
target = i + length
|
|
while i < target
|
|
#{body}
|
|
i += 1
|
|
end
|
|
acc
|
|
end unless method_defined?(:#{inner_meth_name}_single_key)
|
|
RUBY_EVAL
|
|
|
|
self.class.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
|
def #{outer_meth_name}_multiple_keys(cache, keys, loop_count)
|
|
total_length = keys.size
|
|
acc = 0
|
|
inc = 100
|
|
loop_count.times do
|
|
i = 0
|
|
pre_loop_inc = total_length % inc
|
|
acc = #{inner_meth_name}_multiple_keys(cache, keys, i, pre_loop_inc, acc)
|
|
i += pre_loop_inc
|
|
while i < total_length
|
|
acc = #{inner_meth_name}_multiple_keys(cache, keys, i, inc, acc)
|
|
i += inc
|
|
end
|
|
end
|
|
acc
|
|
end unless method_defined?(:#{outer_meth_name}_multiple_keys)
|
|
RUBY_EVAL
|
|
|
|
self.class.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
|
def #{outer_meth_name}_single_key(cache, key, loop_count)
|
|
acc = 0
|
|
i = 0
|
|
inc = 100
|
|
|
|
pre_loop_inc = loop_count % inc
|
|
acc = #{inner_meth_name}_single_key(cache, key, i, pre_loop_inc, acc)
|
|
i += pre_loop_inc
|
|
|
|
while i < loop_count
|
|
acc = #{inner_meth_name}_single_key(cache, key, i, inc, acc)
|
|
i += inc
|
|
end
|
|
acc
|
|
end unless method_defined?(:#{outer_meth_name}_single_key)
|
|
RUBY_EVAL
|
|
outer_meth_name
|
|
end
|
|
|
|
def to_keys_array(key_count)
|
|
arr = []
|
|
key_count.times {|i| arr << i}
|
|
arr
|
|
end
|
|
|
|
def to_hash_collision_keys_array(key_count)
|
|
to_keys_array(key_count).map {|key| ThreadSafe::Test::HashCollisionKey(key)}
|
|
end
|
|
|
|
def sum(result)
|
|
result.inject(0) {|acc, i| acc + i}
|
|
end
|
|
|
|
def assert_standard_accumulator_test_result(result, cache, options, keys)
|
|
assert_all_key_mappings_exist(cache, keys)
|
|
assert_equal(options[:key_count], sum(result))
|
|
assert_equal(options[:key_count], cache.size)
|
|
end
|
|
|
|
def assert_all_key_mappings_exist(cache, keys, all_must_exist = true)
|
|
keys.each do |key|
|
|
if (value = cache[key]) || all_must_exist
|
|
assert_equal key, value unless key == value # don't do a bazzilion assertions unless necessary
|
|
end
|
|
end
|
|
end
|
|
|
|
def assert_count_up(result, cache, options, keys)
|
|
keys.each do |key|
|
|
unless value = cache[key]
|
|
assert value
|
|
end
|
|
end
|
|
assert_equal(sum(cache.values), sum(result))
|
|
assert_equal(options[:key_count], cache.size)
|
|
end
|
|
end unless RUBY_VERSION =~ /1\.8/ || ENV['TRAVIS']
|