diff --git a/test/test_helper.rb b/test/test_helper.rb index 03a5389..b2e6d89 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -94,4 +94,13 @@ def shard_keys(cache, shard) shard_keys = cache.send(:connections).assign(namespaced_keys)[shard] shard_keys.map { |key| key.delete_prefix("#{@namespace}:") } end + + def emulating_timeouts + ar_methods = [ :select_all, :delete, :exec_insert_all ] + stub_matcher = ActiveRecord::Base.connection.class.any_instance + ar_methods.each { |method| stub_matcher.stubs(method).raises(ActiveRecord::StatementTimeout) } + yield + ensure + ar_methods.each { |method| stub_matcher.unstub(method) } + end end diff --git a/test/unit/behaviors.rb b/test/unit/behaviors.rb index 4099847..df23d53 100644 --- a/test/unit/behaviors.rb +++ b/test/unit/behaviors.rb @@ -16,6 +16,22 @@ require_relative "behaviors_rails_7_2/failure_safety_behavior" require_relative "behaviors_rails_7_2/failure_raising_behavior" require_relative "behaviors_rails_7_2/local_cache_behavior" +elsif Rails::VERSION::MAJOR == 8 && Rails::VERSION::MINOR == 0 + require_relative "behaviors_rails_8_0/cache_delete_matched_behavior" + require_relative "behaviors_rails_8_0/cache_increment_decrement_behavior" + require_relative "behaviors_rails_8_0/cache_instrumentation_behavior" + require_relative "behaviors_rails_8_0/cache_logging_behavior" + require_relative "behaviors_rails_8_0/cache_store_behavior" + require_relative "behaviors_rails_8_0/cache_store_version_behavior" + require_relative "behaviors_rails_8_0/cache_store_coder_behavior" + require_relative "behaviors_rails_8_0/cache_store_compression_behavior" + require_relative "behaviors_rails_8_0/cache_store_format_version_behavior" + require_relative "behaviors_rails_8_0/cache_store_serializer_behavior" + require_relative "behaviors_rails_8_0/connection_pool_behavior" + require_relative "behaviors_rails_8_0/encoded_key_cache_behavior" + require_relative "behaviors_rails_8_0/failure_safety_behavior" + require_relative "behaviors_rails_8_0/failure_raising_behavior" + require_relative "behaviors_rails_8_0/local_cache_behavior" else require_relative "behaviors/cache_delete_matched_behavior" require_relative "behaviors/cache_increment_decrement_behavior" diff --git a/test/unit/behaviors/cache_increment_decrement_behavior.rb b/test/unit/behaviors/cache_increment_decrement_behavior.rb index a7d17a0..ee5da2a 100644 --- a/test/unit/behaviors/cache_increment_decrement_behavior.rb +++ b/test/unit/behaviors/cache_increment_decrement_behavior.rb @@ -31,13 +31,25 @@ def test_decrement assert_equal -100, missing end - def test_ttl_is_not_updated + def test_read_counter_and_write_counter + key = SecureRandom.uuid + @cache.write_counter(key, 1) + assert_equal 1, @cache.read(key, raw: true).to_i + + assert_equal 1, @cache.read_counter(key) + assert_equal 2, @cache.increment(key) + assert_equal 2, @cache.read_counter(key) + + assert_nil @cache.read_counter(SecureRandom.alphanumeric) + end + + def test_ttl_isnt_updated key = SecureRandom.uuid - assert_equal 1, @cache.increment(key, 1, expires_in: 1) - assert_equal 2, @cache.increment(key, 1, expires_in: 5000) + assert_equal 1, @cache.increment(key, expires_in: 1) + assert_equal 2, @cache.increment(key, expires_in: 5000) - # Having to sleep two seconds in a test is bad, but we're testing + # having to sleep two seconds in a test is bad, but we're testing # a wide range of backends with different TTL mechanisms, most without # subsecond granularity, so this is the only reliable way. sleep 2 diff --git a/test/unit/behaviors/cache_instrumentation_behavior.rb b/test/unit/behaviors/cache_instrumentation_behavior.rb index ef5e642..9f59e35 100644 --- a/test/unit/behaviors/cache_instrumentation_behavior.rb +++ b/test/unit/behaviors/cache_instrumentation_behavior.rb @@ -8,9 +8,7 @@ def test_write_multi_instrumentation value_2 = SecureRandom.alphanumeric writes = { key_1 => value_1, key_2 => value_2 } - events = with_instrumentation "write_multi" do - @cache.write_multi(writes) - end + events = capture_notifications("cache_write_multi.active_support") { @cache.write_multi(writes) } assert_equal %w[ cache_write_multi.active_support ], events.map(&:name) assert_nil events[0].payload[:super_operation] @@ -23,7 +21,7 @@ def test_instrumentation_with_fetch_multi_as_super_operation key_2 = SecureRandom.uuid - events = with_instrumentation "read_multi" do + events = capture_notifications("cache_read_multi.active_support") do @cache.fetch_multi(key_2, key_1) { |key| key * 2 } end @@ -35,17 +33,14 @@ def test_instrumentation_with_fetch_multi_as_super_operation end def test_fetch_multi_instrumentation_order_of_operations - operations = [] - callback = ->(name, *) { operations << name } - key_1 = SecureRandom.uuid key_2 = SecureRandom.uuid - ActiveSupport::Notifications.subscribed(callback, /^cache_(read_multi|write_multi)\.active_support$/) do + operations = capture_notifications(/^cache_(read_multi|write_multi)\.active_support$/) do @cache.fetch_multi(key_1, key_2) { |key| key * 2 } end - assert_equal %w[ cache_read_multi.active_support cache_write_multi.active_support ], operations + assert_equal %w[ cache_read_multi.active_support cache_write_multi.active_support ], operations.map(&:name) end def test_read_multi_instrumentation @@ -54,9 +49,7 @@ def test_read_multi_instrumentation key_2 = SecureRandom.uuid - events = with_instrumentation "read_multi" do - @cache.read_multi(key_2, key_1) - end + events = capture_notifications("cache_read_multi.active_support") { @cache.read_multi(key_2, key_1) } assert_equal %w[ cache_read_multi.active_support ], events.map(&:name) assert_equal [normalized_key(key_2), normalized_key(key_1)], events[0].payload[:key] @@ -64,13 +57,59 @@ def test_read_multi_instrumentation assert_equal @cache.class.name, events[0].payload[:store] end + def test_read_instrumentation + key = SecureRandom.uuid + @cache.write(key, SecureRandom.alphanumeric) + + events = capture_notifications("cache_read.active_support") { @cache.read(key) } + + assert_equal %w[ cache_read.active_support ], events.map(&:name) + assert_equal normalized_key(key), events[0].payload[:key] + assert_same true, events[0].payload[:hit] + assert_equal @cache.class.name, events[0].payload[:store] + end + + def test_write_instrumentation + key = SecureRandom.uuid + + events = capture_notifications("cache_write.active_support") { @cache.write(key, SecureRandom.alphanumeric) } + + assert_equal %w[ cache_write.active_support ], events.map(&:name) + assert_equal normalized_key(key), events[0].payload[:key] + assert_equal @cache.class.name, events[0].payload[:store] + end + + def test_delete_instrumentation + key = SecureRandom.uuid + + options = { namespace: "foo" } + + events = capture_notifications("cache_delete.active_support") { @cache.delete(key, options) } + + assert_equal %w[ cache_delete.active_support ], events.map(&:name) + assert_equal normalized_key(key, options), events[0].payload[:key] + assert_equal @cache.class.name, events[0].payload[:store] + assert_equal "foo", events[0].payload[:namespace] + end + + def test_delete_multi_instrumentation + key_1 = SecureRandom.uuid + key_2 = SecureRandom.uuid + + options = { namespace: "foo" } + + events = capture_notifications("cache_delete_multi.active_support") { @cache.delete_multi([key_2, key_1], options) } + + assert_equal %w[ cache_delete_multi.active_support ], events.map(&:name) + assert_equal [normalized_key(key_2, options), normalized_key(key_1, options)], events[0].payload[:key] + assert_equal @cache.class.name, events[0].payload[:store] + end + def test_increment_instrumentation key_1 = SecureRandom.uuid @cache.write(key_1, 0) - events = with_instrumentation "increment" do - @cache.increment(key_1) - end + events = capture_notifications("cache_increment.active_support") { @cache.increment(key_1) } assert_equal %w[ cache_increment.active_support ], events.map(&:name) assert_equal normalized_key(key_1), events[0].payload[:key] @@ -82,9 +121,7 @@ def test_decrement_instrumentation key_1 = SecureRandom.uuid @cache.write(key_1, 0) - events = with_instrumentation "decrement" do - @cache.decrement(key_1) - end + events = capture_notifications("cache_decrement.active_support") { @cache.decrement(key_1) } assert_equal %w[ cache_decrement.active_support ], events.map(&:name) assert_equal normalized_key(key_1), events[0].payload[:key] @@ -92,18 +129,7 @@ def test_decrement_instrumentation end private - def with_instrumentation(method) - event_name = "cache_#{method}.active_support" - - [].tap do |events| - ActiveSupport::Notifications.subscribe(event_name) { |event| events << event } - yield - end - ensure - ActiveSupport::Notifications.unsubscribe event_name - end - - def normalized_key(key) - @cache.send(:normalize_key, key, @cache.options) + def normalized_key(key, options = nil) + @cache.send(:normalize_key, key, options) end end diff --git a/test/unit/behaviors/cache_store_behavior.rb b/test/unit/behaviors/cache_store_behavior.rb index fdf50b4..4b81c82 100644 --- a/test/unit/behaviors/cache_store_behavior.rb +++ b/test/unit/behaviors/cache_store_behavior.rb @@ -647,6 +647,26 @@ def test_race_condition_protection end end + def test_fetch_race_condition_protection + time = Time.now + key = SecureRandom.uuid + value = SecureRandom.uuid + expires_in = 60 + + @cache.write(key, value, expires_in:) + Time.stub(:now, time + expires_in + 1) do + fetched_value = @cache.fetch(key, expires_in:, race_condition_ttl: 10) do + SecureRandom.uuid + end + assert_not_equal fetched_value, value + assert_not_nil fetched_value + end + + Time.stub(:now, time + 2 * expires_in) do + assert_not_nil @cache.read(key) + end + end + def test_fetch_multi_race_condition_protection time = Time.now key = SecureRandom.uuid @@ -722,6 +742,26 @@ def test_setting_options_in_fetch_block_does_not_change_cache_options end end + def test_configuring_store_with_raw + cache = lookup_store(raw: true) + cache.write("foo", "bar") + assert_equal "bar", cache.read("foo") + end + + def test_max_key_size + cache = lookup_store(max_key_size: 64) + key = "foobar" * 20 + cache.write(key, "bar") + assert_equal "bar", cache.read(key) + end + + def test_max_key_size_disabled + cache = lookup_store(max_key_size: false) + key = "a" * 1000 + cache.write(key, "bar") + assert_equal "bar", cache.read(key) + end + private def with_raise_on_invalid_cache_expiration_time(new_value, &block) old_value = ActiveSupport::Cache::Store.raise_on_invalid_cache_expiration_time diff --git a/test/unit/behaviors/connection_pool_behavior.rb b/test/unit/behaviors/connection_pool_behavior.rb index cb59c04..539a227 100644 --- a/test/unit/behaviors/connection_pool_behavior.rb +++ b/test/unit/behaviors/connection_pool_behavior.rb @@ -7,10 +7,10 @@ def test_connection_pool threads = [] emulating_latency do - cache = ActiveSupport::Cache.lookup_store(*store, { pool: { size: 2, timeout: 1 } }.merge(store_options)) + cache = ActiveSupport::Cache.lookup_store(*store, { pool: { size: 2, timeout: 0.1 } }.merge(store_options)) cache.read("foo") - assert_raises Timeout::Error do + assert_nothing_raised do # One of the three threads will fail in 1 second because our pool size # is only two. 3.times do @@ -28,6 +28,36 @@ def test_connection_pool Thread.report_on_exception = original_report_on_exception end + def test_connection_pool_fetch + Thread.report_on_exception, original_report_on_exception = false, Thread.report_on_exception + + threads = [] + results = [] + + emulating_latency do + cache = ActiveSupport::Cache.lookup_store(*store, { pool: { size: 2, timeout: 0.1 } }.merge(store_options)) + value = SecureRandom.alphanumeric + base_key = "latency:#{SecureRandom.uuid}" + + assert_nothing_raised do + # One of the three threads will fail in 1 second because our pool size + # is only two. + 3.times do |i| + threads << Thread.new do + cache.fetch("#{base_key}:#{i}") { value } + end + end + + results = threads.map(&:value) + assert_equal [value] * 3, results, "All threads should return the same value" + end + ensure + threads.each(&:kill) + end + ensure + Thread.report_on_exception = original_report_on_exception + end + def test_no_connection_pool threads = [] diff --git a/test/unit/behaviors/local_cache_behavior.rb b/test/unit/behaviors/local_cache_behavior.rb index f7ff67e..001bb69 100644 --- a/test/unit/behaviors/local_cache_behavior.rb +++ b/test/unit/behaviors/local_cache_behavior.rb @@ -3,13 +3,13 @@ module LocalCacheBehavior def test_instrumentation_with_local_cache key = SecureRandom.uuid - events = with_instrumentation "write" do + events = capture_notifications("cache_write.active_support") do @cache.write(key, SecureRandom.uuid) end assert_equal @cache.class.name, events[0].payload[:store] @cache.with_local_cache do - events = with_instrumentation "read" do + events = capture_notifications("cache_read.active_support") do @cache.read(key) @cache.read(key) end @@ -124,6 +124,17 @@ def test_local_cache_fetch end end + def test_local_cache_fetch_on_miss + key = SecureRandom.uuid + @cache.with_local_cache do + assert_equal false, @cache.exist?(key) + value = @cache.fetch(key) { "fetch-yielded" } + assert_equal "fetch-yielded", value + + assert_equal "fetch-yielded", @peek.read(key) + end + end + def test_local_cache_of_write_nil key = SecureRandom.uuid value = SecureRandom.alphanumeric @@ -215,14 +226,24 @@ def test_local_cache_of_decrement end def test_local_cache_of_fetch_multi - key = SecureRandom.uuid - other_key = SecureRandom.uuid + existing_key = SecureRandom.uuid + known_missing_key = SecureRandom.uuid + unknown_key = SecureRandom.uuid + @cache.with_local_cache do - @cache.fetch_multi(key, other_key) { |_key| true } - @peek.delete(key) - @peek.delete(other_key) - assert_equal true, @cache.read(key) - assert_equal true, @cache.read(other_key) + @cache.fetch(existing_key) { "exist" } + assert_equal false, @cache.exist?("known-missing") + + results = @cache.fetch_multi(known_missing_key, existing_key, unknown_key) { "fetch-yielded" } + expected = { + known_missing_key => "fetch-yielded", + existing_key => "exist", + unknown_key => "fetch-yielded", + } + assert_equal(expected, results) + + results = @peek.read_multi(known_missing_key, existing_key, unknown_key) + assert_equal expected, results end end @@ -242,6 +263,37 @@ def test_local_cache_of_read_multi end end + def test_local_cache_of_read_multi_with_expiry + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + @cache.with_local_cache do + time = Time.now + @cache.write(key, value, expires_in: 60) + assert_equal value, @cache.read_multi(key)[key] + Time.stub(:now, time + 61) do + assert_nil @cache.read_multi(key)[key] + end + end + end + + def test_local_cache_of_read_multi_with_versions + model = Struct.new(:to_param, :cache_version) + + @cache.with_local_cache do + thing = model.new(1, 1) + key = ["foo", thing] + + @cache.write(key, "contents") + + assert_equal "contents", @cache.read(key) + assert_equal "contents", @cache.read_multi(key)[key] + + thing.cache_version = "002" + assert_nil @cache.read(key) + assert_nil @cache.read_multi(key)[key] + end + end + def test_local_cache_of_read_multi_prioritizes_local_entries key = "key#{rand}" @cache.with_local_cache do diff --git a/test/unit/behaviors_rails_8_0/cache_delete_matched_behavior.rb b/test/unit/behaviors_rails_8_0/cache_delete_matched_behavior.rb new file mode 100644 index 0000000..ed8eba8 --- /dev/null +++ b/test/unit/behaviors_rails_8_0/cache_delete_matched_behavior.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module CacheDeleteMatchedBehavior + def test_delete_matched + @cache.write("foo", "bar") + @cache.write("fu", "baz") + @cache.write("foo/bar", "baz") + @cache.write("fu/baz", "bar") + @cache.delete_matched(/oo/) + assert_not @cache.exist?("foo") + assert @cache.exist?("fu") + assert_not @cache.exist?("foo/bar") + assert @cache.exist?("fu/baz") + end +end diff --git a/test/unit/behaviors_rails_8_0/cache_increment_decrement_behavior.rb b/test/unit/behaviors_rails_8_0/cache_increment_decrement_behavior.rb new file mode 100644 index 0000000..a7d17a0 --- /dev/null +++ b/test/unit/behaviors_rails_8_0/cache_increment_decrement_behavior.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module CacheIncrementDecrementBehavior + def test_increment + key = SecureRandom.uuid + @cache.write(key, 1, raw: true) + assert_equal 1, @cache.read(key, raw: true).to_i + assert_equal 2, @cache.increment(key) + assert_equal 2, @cache.read(key, raw: true).to_i + assert_equal 3, @cache.increment(key) + assert_equal 3, @cache.read(key, raw: true).to_i + + missing = @cache.increment(SecureRandom.alphanumeric) + assert_equal 1, missing + missing = @cache.increment(SecureRandom.alphanumeric, 100) + assert_equal 100, missing + end + + def test_decrement + key = SecureRandom.uuid + @cache.write(key, 3, raw: true) + assert_equal 3, @cache.read(key, raw: true).to_i + assert_equal 2, @cache.decrement(key) + assert_equal 2, @cache.read(key, raw: true).to_i + assert_equal 1, @cache.decrement(key) + assert_equal 1, @cache.read(key, raw: true).to_i + + missing = @cache.decrement(SecureRandom.alphanumeric) + assert_equal -1, missing + missing = @cache.decrement(SecureRandom.alphanumeric, 100) + assert_equal -100, missing + end + + def test_ttl_is_not_updated + key = SecureRandom.uuid + + assert_equal 1, @cache.increment(key, 1, expires_in: 1) + assert_equal 2, @cache.increment(key, 1, expires_in: 5000) + + # Having to sleep two seconds in a test is bad, but we're testing + # a wide range of backends with different TTL mechanisms, most without + # subsecond granularity, so this is the only reliable way. + sleep 2 + + assert_nil @cache.read(key, raw: true) + end +end diff --git a/test/unit/behaviors_rails_8_0/cache_instrumentation_behavior.rb b/test/unit/behaviors_rails_8_0/cache_instrumentation_behavior.rb new file mode 100644 index 0000000..ef5e642 --- /dev/null +++ b/test/unit/behaviors_rails_8_0/cache_instrumentation_behavior.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module CacheInstrumentationBehavior + def test_write_multi_instrumentation + key_1 = SecureRandom.uuid + key_2 = SecureRandom.uuid + value_1 = SecureRandom.alphanumeric + value_2 = SecureRandom.alphanumeric + writes = { key_1 => value_1, key_2 => value_2 } + + events = with_instrumentation "write_multi" do + @cache.write_multi(writes) + end + + assert_equal %w[ cache_write_multi.active_support ], events.map(&:name) + assert_nil events[0].payload[:super_operation] + assert_equal({ normalized_key(key_1) => value_1, normalized_key(key_2) => value_2 }, events[0].payload[:key]) + end + + def test_instrumentation_with_fetch_multi_as_super_operation + key_1 = SecureRandom.uuid + @cache.write(key_1, SecureRandom.alphanumeric) + + key_2 = SecureRandom.uuid + + events = with_instrumentation "read_multi" do + @cache.fetch_multi(key_2, key_1) { |key| key * 2 } + end + + assert_equal %w[ cache_read_multi.active_support ], events.map(&:name) + assert_equal :fetch_multi, events[0].payload[:super_operation] + assert_equal [normalized_key(key_2), normalized_key(key_1)], events[0].payload[:key] + assert_equal [normalized_key(key_1)], events[0].payload[:hits] + assert_equal @cache.class.name, events[0].payload[:store] + end + + def test_fetch_multi_instrumentation_order_of_operations + operations = [] + callback = ->(name, *) { operations << name } + + key_1 = SecureRandom.uuid + key_2 = SecureRandom.uuid + + ActiveSupport::Notifications.subscribed(callback, /^cache_(read_multi|write_multi)\.active_support$/) do + @cache.fetch_multi(key_1, key_2) { |key| key * 2 } + end + + assert_equal %w[ cache_read_multi.active_support cache_write_multi.active_support ], operations + end + + def test_read_multi_instrumentation + key_1 = SecureRandom.uuid + @cache.write(key_1, SecureRandom.alphanumeric) + + key_2 = SecureRandom.uuid + + events = with_instrumentation "read_multi" do + @cache.read_multi(key_2, key_1) + end + + assert_equal %w[ cache_read_multi.active_support ], events.map(&:name) + assert_equal [normalized_key(key_2), normalized_key(key_1)], events[0].payload[:key] + assert_equal [normalized_key(key_1)], events[0].payload[:hits] + assert_equal @cache.class.name, events[0].payload[:store] + end + + def test_increment_instrumentation + key_1 = SecureRandom.uuid + @cache.write(key_1, 0) + + events = with_instrumentation "increment" do + @cache.increment(key_1) + end + + assert_equal %w[ cache_increment.active_support ], events.map(&:name) + assert_equal normalized_key(key_1), events[0].payload[:key] + assert_equal @cache.class.name, events[0].payload[:store] + end + + + def test_decrement_instrumentation + key_1 = SecureRandom.uuid + @cache.write(key_1, 0) + + events = with_instrumentation "decrement" do + @cache.decrement(key_1) + end + + assert_equal %w[ cache_decrement.active_support ], events.map(&:name) + assert_equal normalized_key(key_1), events[0].payload[:key] + assert_equal @cache.class.name, events[0].payload[:store] + end + + private + def with_instrumentation(method) + event_name = "cache_#{method}.active_support" + + [].tap do |events| + ActiveSupport::Notifications.subscribe(event_name) { |event| events << event } + yield + end + ensure + ActiveSupport::Notifications.unsubscribe event_name + end + + def normalized_key(key) + @cache.send(:normalize_key, key, @cache.options) + end +end diff --git a/test/unit/behaviors_rails_8_0/cache_logging_behavior.rb b/test/unit/behaviors_rails_8_0/cache_logging_behavior.rb new file mode 100644 index 0000000..3333bbd --- /dev/null +++ b/test/unit/behaviors_rails_8_0/cache_logging_behavior.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/with" + +module CacheLoggingBehavior + def test_fetch_logging + assert_logs(/Cache read: #{key_pattern("foo")}/) do + @cache.fetch("foo") + end + + assert_logs(/Cache read: #{key_pattern("foo", namespace: "bar")}/) do + @cache.fetch("foo", namespace: "bar") + end + end + + def test_read_logging + assert_logs(/Cache read: #{key_pattern("foo")}/) do + @cache.read("foo") + end + + assert_logs(/Cache read: #{key_pattern("foo", namespace: "bar")}/) do + @cache.read("foo", namespace: "bar") + end + end + + def test_write_logging + assert_logs(/Cache write: #{key_pattern("foo")}/) do + @cache.write("foo", "qux") + end + + assert_logs(/Cache write: #{key_pattern("foo", namespace: "bar")}/) do + @cache.write("foo", "qux", namespace: "bar") + end + end + + def test_delete_logging + assert_logs(/Cache delete: #{key_pattern("foo")}/) do + @cache.delete("foo") + end + + assert_logs(/Cache delete: #{key_pattern("foo", namespace: "bar")}/) do + @cache.delete("foo", namespace: "bar") + end + end + + def test_exist_logging + assert_logs(/Cache exist\?: #{key_pattern("foo")}/) do + @cache.exist?("foo") + end + + assert_logs(/Cache exist\?: #{key_pattern("foo", namespace: "bar")}/) do + @cache.exist?("foo", namespace: "bar") + end + end + + def test_read_multi_logging + assert_logs("Cache read_multi: 1 key(s)") { @cache.read_multi("foo") } + assert_logs("Cache read_multi: 2 key(s)") { @cache.read_multi("foo", "bar") } + end + + def test_write_multi_logging + key = SecureRandom.uuid + assert_logs("Cache write_multi: 1 key(s)") { @cache.write_multi("#{key}1" => 1) } + assert_logs("Cache write_multi: 2 key(s)") { @cache.write_multi("#{key}1" => 1, "#{key}2" => 2) } + end + + def test_delete_multi_logging + key = SecureRandom.uuid + assert_logs("Cache delete_multi: 1 key(s)") { @cache.delete_multi(["#{key}1"]) } + assert_logs("Cache delete_multi: 2 key(s)") { @cache.delete_multi(["#{key}1", "#{key}2"]) } + end + + private + def assert_logs(pattern, &block) + io = StringIO.new + ActiveSupport::Cache::Store.with(logger: Logger.new(io, level: :debug), &block) + assert_match pattern, io.string + end + + def key_pattern(key, namespace: @namespace) + /#{Regexp.escape namespace.to_s}#{":" if namespace}#{Regexp.escape key}/ + end +end diff --git a/test/unit/behaviors_rails_8_0/cache_store_behavior.rb b/test/unit/behaviors_rails_8_0/cache_store_behavior.rb new file mode 100644 index 0000000..fdf50b4 --- /dev/null +++ b/test/unit/behaviors_rails_8_0/cache_store_behavior.rb @@ -0,0 +1,746 @@ +# frozen_string_literal: true + +require "active_support/core_ext/numeric/time" +require "active_support/error_reporter/test_helper" + +# Tests the base functionality that should be identical across all cache stores. +module CacheStoreBehavior + def test_should_read_and_write_strings + key = SecureRandom.uuid + assert_equal true, @cache.write(key, "bar") + assert_equal "bar", @cache.read(key) + end + + def test_should_overwrite + key = SecureRandom.uuid + assert_equal true, @cache.write(key, "bar") + assert_equal true, @cache.write(key, "baz") + assert_equal "baz", @cache.read(key) + end + + def test_fetch_without_cache_miss + key = SecureRandom.uuid + @cache.write(key, "bar") + assert_not_called(@cache, :write) do + assert_equal "bar", @cache.fetch(key) { "baz" } + end + end + + def test_fetch_with_cache_miss + key = SecureRandom.uuid + assert_called_with(@cache, :write, [key, "baz", @cache.options]) do + assert_equal "baz", @cache.fetch(key) { "baz" } + end + end + + def test_fetch_with_cache_miss_passes_key_to_block + cache_miss = false + key = SecureRandom.alphanumeric(10) + assert_equal 10, @cache.fetch(key) { |key| cache_miss = true; key.length } + assert cache_miss + + cache_miss = false + assert_equal 10, @cache.fetch(key) { |fetch_key| cache_miss = true; fetch_key.length } + assert_not cache_miss + end + + def test_fetch_with_dynamic_options + key = SecureRandom.uuid + expiry = 10.minutes.from_now + expected_options = @cache.options.dup + expected_options.delete(:expires_in) + expected_options.merge!( + expires_at: expiry, + version: "v42", + ) + + assert_called_with(@cache, :write, [key, "bar", expected_options]) do + @cache.fetch(key) do |key, options| + assert_equal @cache.options[:expires_in], options.expires_in + assert_nil options.expires_at + assert_nil options.version + + options.expires_at = expiry + options.version = "v42" + + assert_nil options.expires_in + assert_equal expiry, options.expires_at + assert_equal "v42", options.version + + "bar" + end + end + end + + def test_fetch_with_forced_cache_miss + key = SecureRandom.uuid + @cache.write(key, "bar") + assert_not_called(@cache, :read) do + assert_called_with(@cache, :write, [key, "bar", @cache.options.merge(force: true)]) do + @cache.fetch(key, force: true) { "bar" } + end + end + end + + def test_fetch_with_cached_nil + key = SecureRandom.uuid + @cache.write(key, nil) + assert_not_called(@cache, :write) do + assert_nil @cache.fetch(key) { "baz" } + end + end + + def test_fetch_cache_miss_with_skip_nil + key = SecureRandom.uuid + assert_not_called(@cache, :write) do + assert_nil @cache.fetch(key, skip_nil: true) { nil } + assert_equal false, @cache.exist?("foo") + end + end + + def test_fetch_with_forced_cache_miss_with_block + key = SecureRandom.uuid + @cache.write(key, "bar") + assert_equal "foo_bar", @cache.fetch(key, force: true) { "foo_bar" } + end + + def test_fetch_with_forced_cache_miss_without_block + key = SecureRandom.uuid + @cache.write(key, "bar") + assert_raises(ArgumentError) do + @cache.fetch(key, force: true) + end + + assert_equal "bar", @cache.read(key) + end + + def test_should_read_and_write_hash + key = SecureRandom.uuid + assert_equal true, @cache.write(key, a: "b") + assert_equal({ a: "b" }, @cache.read(key)) + end + + def test_should_read_and_write_integer + key = SecureRandom.uuid + assert_equal true, @cache.write(key, 1) + assert_equal 1, @cache.read(key) + end + + def test_should_read_and_write_nil + key = SecureRandom.uuid + assert_equal true, @cache.write(key, nil) + assert_nil @cache.read(key) + end + + def test_should_read_and_write_false + key = SecureRandom.uuid + assert_equal true, @cache.write(key, false) + assert_equal false, @cache.read(key) + end + + def test_read_multi + key = SecureRandom.uuid + @cache.write(key, "bar") + other_key = SecureRandom.uuid + @cache.write(other_key, "baz") + @cache.write(SecureRandom.alphanumeric, "biz") + assert_equal({ key => "bar", other_key => "baz" }, @cache.read_multi(key, other_key)) + end + + def test_read_multi_empty_list + assert_equal({}, @cache.read_multi()) + end + + def test_read_multi_with_expires + time = Time.now + key = SecureRandom.uuid + other_key = SecureRandom.uuid + @cache.write(key, "bar", expires_in: 10) + @cache.write(other_key, "baz") + @cache.write(SecureRandom.alphanumeric, "biz") + Time.stub(:now, time + 11) do + assert_equal({ other_key => "baz" }, @cache.read_multi(other_key, SecureRandom.alphanumeric)) + end + end + + def test_write_multi + key = SecureRandom.uuid + @cache.write_multi("#{key}1" => 1, "#{key}2" => 2) + assert_equal 1, @cache.read("#{key}1") + assert_equal 2, @cache.read("#{key}2") + end + + def test_write_multi_empty_hash + assert @cache.write_multi({}) + end + + def test_write_multi_expires_in + key = SecureRandom.uuid + @cache.write_multi({ key => 1 }, expires_in: 10) + + travel(11.seconds) do + assert_nil @cache.read(key) + end + end + + def test_fetch_multi + key = SecureRandom.uuid + other_key = SecureRandom.uuid + third_key = SecureRandom.alphanumeric + @cache.write(key, "bar") + @cache.write(other_key, "biz") + + values = @cache.fetch_multi(key, other_key, third_key) { |value| value * 2 } + + assert_equal({ key => "bar", other_key => "biz", third_key => (third_key * 2) }, values) + assert_equal((third_key * 2), @cache.read(third_key)) + end + + def test_fetch_multi_empty_hash + assert_equal({}, @cache.fetch_multi() { raise "Not called" }) + end + + def test_fetch_multi_without_expires_in + key = SecureRandom.uuid + other_key = SecureRandom.uuid + third_key = SecureRandom.alphanumeric + @cache.write(key, "bar") + @cache.write(other_key, "biz") + + values = @cache.fetch_multi(key, third_key, other_key, expires_in: nil) { |value| value * 2 } + + assert_equal({ key => "bar", third_key => (third_key * 2), other_key => "biz" }, values) + assert_equal((third_key * 2), @cache.read(third_key)) + end + + def test_fetch_multi_with_objects + key = SecureRandom.uuid + other_key = SecureRandom.uuid + cache_struct = Struct.new(:cache_key, :title) + foo = cache_struct.new(key, "FOO!") + bar = cache_struct.new(other_key) + + @cache.write(other_key, "BAM!") + + values = @cache.fetch_multi(foo, bar) { |object| object.title } + + assert_equal({ foo => "FOO!", bar => "BAM!" }, values) + end + + def test_fetch_multi_returns_ordered_names + key = SecureRandom.alphanumeric.downcase + other_key = SecureRandom.alphanumeric.downcase + third_key = SecureRandom.alphanumeric.downcase + @cache.write(key, "BAM") + + values = @cache.fetch_multi(other_key, third_key, key) { |key| key.upcase } + + assert_equal([other_key, third_key, key], values.keys) + assert_equal([other_key.upcase, third_key.upcase, "BAM"], values.values) + end + + def test_fetch_multi_without_block + assert_raises(ArgumentError) do + @cache.fetch_multi(SecureRandom.alphanumeric) + end + end + + def test_fetch_multi_with_forced_cache_miss + key = SecureRandom.uuid + other_key = SecureRandom.uuid + @cache.write(key, "bar") + + values = @cache.fetch_multi(key, other_key, force: true) { |value| value * 2 } + + assert_equal({ key => (key * 2), other_key => (other_key * 2) }, values) + assert_equal(key * 2, @cache.read(key)) + assert_equal(other_key * 2, @cache.read(other_key)) + end + + def test_fetch_multi_with_skip_nil + key = SecureRandom.uuid + other_key = SecureRandom.uuid + + values = @cache.fetch_multi(key, other_key, skip_nil: true) { |k| k == key ? k : nil } + + assert_equal({ key => key, other_key => nil }, values) + assert_equal(false, @cache.exist?(other_key)) + end + + def test_fetch_multi_uses_write_multi_entries_store_provider_interface + assert_called(@cache, :write_multi_entries) do + @cache.fetch_multi "a", "b", "c" do |key| + key * 2 + end + end + end + + def test_cache_key + key = SecureRandom.uuid + klass = Class.new do + def initialize(key) + @key = key + end + def cache_key + @key + end + end + @cache.write(klass.new(key), "bar") + assert_equal "bar", @cache.read(key) + end + + def test_param_as_cache_key + key = SecureRandom.uuid + klass = Class.new do + def initialize(key) + @key = key + end + def to_param + @key + end + end + @cache.write(klass.new(key), "bar") + assert_equal "bar", @cache.read(key) + end + + def test_unversioned_cache_key + key = SecureRandom.uuid + klass = Class.new do + def initialize(key) + @key = key + end + def cache_key + @key + end + def cache_key_with_version + "#{@key}-v1" + end + end + @cache.write(klass.new(key), "bar") + assert_equal "bar", @cache.read(key) + end + + def test_array_as_cache_key + key = SecureRandom.uuid + @cache.write([key, "foo"], "bar") + assert_equal "bar", @cache.read("#{key}/foo") + end + + InstanceTest = Struct.new(:name, :id) do + def cache_key + "#{name}/#{id}" + end + + def to_param + "hello" + end + end + + def test_array_with_single_instance_as_cache_key_uses_cache_key_method + key = SecureRandom.alphanumeric + other_key = SecureRandom.alphanumeric + test_instance_one = InstanceTest.new(key, 1) + test_instance_two = InstanceTest.new(other_key, 2) + + @cache.write([test_instance_one], "one") + @cache.write([test_instance_two], "two") + + assert_equal "one", @cache.read([test_instance_one]) + assert_equal "two", @cache.read([test_instance_two]) + end + + def test_array_with_multiple_instances_as_cache_key_uses_cache_key_method + key = SecureRandom.alphanumeric + other_key = SecureRandom.alphanumeric + third_key = SecureRandom.alphanumeric + test_instance_one = InstanceTest.new(key, 1) + test_instance_two = InstanceTest.new(other_key, 2) + test_instance_three = InstanceTest.new(third_key, 3) + + @cache.write([test_instance_one, test_instance_three], "one") + @cache.write([test_instance_two, test_instance_three], "two") + + assert_equal "one", @cache.read([test_instance_one, test_instance_three]) + assert_equal "two", @cache.read([test_instance_two, test_instance_three]) + end + + def test_format_of_expanded_key_for_single_instance + key = SecureRandom.alphanumeric + test_instance_one = InstanceTest.new(key, 1) + + expanded_key = @cache.send(:expanded_key, test_instance_one) + + assert_equal expanded_key, test_instance_one.cache_key + end + + def test_format_of_expanded_key_for_single_instance_in_array + key = SecureRandom.alphanumeric + test_instance_one = InstanceTest.new(key, 1) + + expanded_key = @cache.send(:expanded_key, [test_instance_one]) + + assert_equal expanded_key, test_instance_one.cache_key + end + + def test_hash_as_cache_key + key = SecureRandom.alphanumeric + other_key = SecureRandom.alphanumeric + @cache.write({ key => 1, other_key => 2 }, "bar") + assert_equal "bar", @cache.read({ key => 1, other_key => 2 }) + end + + def test_keys_are_case_sensitive + key = "case_sensitive_key" + @cache.write(key, "bar") + assert_nil @cache.read(key.upcase) + end + + def test_blank_key + invalid_keys = [nil, "", [], {}] + invalid_keys.each do |key| + assert_raises(ArgumentError) { @cache.write(key, "bar") } + assert_raises(ArgumentError) { @cache.read(key) } + assert_raises(ArgumentError) { @cache.delete(key) } + end + + valid_keys = ["foo", ["bar"], { foo: "bar" }, 0, 1, InstanceTest.new("foo", 2)] + valid_keys.each do |key| + assert_nothing_raised { @cache.write(key, "bar") } + assert_nothing_raised { @cache.read(key) } + assert_nothing_raised { @cache.delete(key) } + end + end + + def test_exist + key = SecureRandom.alphanumeric + @cache.write(key, "bar") + assert_equal true, @cache.exist?(key) + assert_equal false, @cache.exist?(SecureRandom.uuid) + end + + def test_nil_exist + key = SecureRandom.alphanumeric + @cache.write(key, nil) + assert @cache.exist?(key) + end + + def test_delete + key = SecureRandom.alphanumeric + @cache.write(key, "bar") + assert @cache.exist?(key) + assert_same true, @cache.delete(key) + assert_not @cache.exist?(key) + end + + def test_delete_returns_false_if_not_exist + key = SecureRandom.alphanumeric + assert_same false, @cache.delete(key) + end + + def test_delete_multi + key = SecureRandom.alphanumeric + @cache.write(key, "bar") + assert @cache.exist?(key) + other_key = SecureRandom.alphanumeric + @cache.write(other_key, "world") + assert @cache.exist?(other_key) + assert_equal 2, @cache.delete_multi([key, SecureRandom.uuid, other_key]) + assert_not @cache.exist?(key) + assert_not @cache.exist?(other_key) + end + + def test_delete_multi_empty_list + assert_equal(0, @cache.delete_multi([])) + end + + def test_original_store_objects_should_not_be_immutable + bar = +"bar" + key = SecureRandom.alphanumeric + @cache.write(key, bar) + assert_nothing_raised { bar.gsub!(/.*/, "baz") } + end + + def test_expires_in + time = Time.local(2008, 4, 24) + + key = SecureRandom.alphanumeric + other_key = SecureRandom.alphanumeric + + Time.stub(:now, time) do + @cache.write(key, "bar", expires_in: 1.minute) + @cache.write(other_key, "spam", expires_in: 2.minute) + assert_equal "bar", @cache.read(key) + assert_equal "spam", @cache.read(other_key) + end + + Time.stub(:now, time + 30) do + assert_equal "bar", @cache.read(key) + assert_equal "spam", @cache.read(other_key) + end + + Time.stub(:now, time + 1.minute + 1.second) do + assert_nil @cache.read(key) + assert_equal "spam", @cache.read(other_key) + end + + Time.stub(:now, time + 2.minute + 1.second) do + assert_nil @cache.read(key) + assert_nil @cache.read(other_key) + end + end + + def test_expires_at + time = Time.local(2008, 4, 24) + + key = SecureRandom.alphanumeric + Time.stub(:now, time) do + @cache.write(key, "bar", expires_at: time + 15.seconds) + assert_equal "bar", @cache.read(key) + end + + Time.stub(:now, time + 10) do + assert_equal "bar", @cache.read(key) + end + + Time.stub(:now, time + 30) do + assert_nil @cache.read(key) + end + end + + def test_expire_in_is_alias_for_expires_in + time = Time.local(2008, 4, 24) + + key = SecureRandom.alphanumeric + Time.stub(:now, time) do + @cache.write(key, "bar", expire_in: 20) + assert_equal "bar", @cache.read(key) + end + + Time.stub(:now, time + 10) do + assert_equal "bar", @cache.read(key) + end + + Time.stub(:now, time + 21) do + assert_nil @cache.read(key) + end + end + + def test_expired_in_is_alias_for_expires_in + time = Time.local(2008, 4, 24) + + key = SecureRandom.alphanumeric + Time.stub(:now, time) do + @cache.write(key, "bar", expired_in: 20) + assert_equal "bar", @cache.read(key) + end + + Time.stub(:now, time + 10) do + assert_equal "bar", @cache.read(key) + end + + Time.stub(:now, time + 21) do + assert_nil @cache.read(key) + end + end + + def test_expires_in_and_expires_at + key = SecureRandom.uuid + error = assert_raises(ArgumentError) do + @cache.write(key, "bar", expire_in: 60, expires_at: 1.minute.from_now) + end + assert_equal "Either :expires_in or :expires_at can be supplied, but not both", error.message + end + + def test_invalid_expiration_time_raises_an_error_when_raise_on_invalid_cache_expiration_time_is_true + with_raise_on_invalid_cache_expiration_time(true) do + key = SecureRandom.uuid + error = assert_raises(ArgumentError) do + @cache.write(key, "bar", expires_in: -60) + end + assert_equal "Cache expiration time is invalid, cannot be negative: -60", error.message + assert_nil @cache.read(key) + end + end + + def test_invalid_expiration_time_reports_and_logs_when_raise_on_invalid_cache_expiration_time_is_false + with_raise_on_invalid_cache_expiration_time(false) do + error_message = "Cache expiration time is invalid, cannot be negative: -60" + report = assert_error_reported(ArgumentError) do + logs = capture_logs do + key = SecureRandom.uuid + @cache.write(key, "bar", expires_in: -60) + assert_equal "bar", @cache.read(key) + end + assert_includes logs, "ArgumentError: #{error_message}" + end + assert_includes report.error.message, error_message + end + end + + def test_expires_in_from_now_raises_an_error + time = 1.minute.from_now + + key = SecureRandom.uuid + error = assert_raises(ArgumentError) do + @cache.write(key, "bar", expires_in: time) + end + assert_equal "expires_in parameter should not be a Time. Did you mean to use expires_at? Got: #{time}", error.message + assert_nil @cache.read(key) + end + + def test_race_condition_protection_skipped_if_not_defined + key = SecureRandom.alphanumeric + @cache.write(key, "bar") + time = @cache.send(:read_entry, @cache.send(:normalize_key, key, {}), **{}).expires_at + + Time.stub(:now, Time.at(time)) do + result = @cache.fetch(key) do + assert_nil @cache.read(key) + "baz" + end + assert_equal "baz", result + end + end + + def test_race_condition_protection_is_limited + time = Time.now + key = SecureRandom.uuid + @cache.write(key, "bar", expires_in: 60) + Time.stub(:now, time + 71) do + result = @cache.fetch(key, race_condition_ttl: 10) do + assert_nil @cache.read(key) + "baz" + end + assert_equal "baz", result + end + end + + def test_race_condition_protection_is_safe + time = Time.now + key = SecureRandom.uuid + @cache.write(key, "bar", expires_in: 60) + Time.stub(:now, time + 61) do + begin + @cache.fetch(key, race_condition_ttl: 10) do + assert_equal "bar", @cache.read(key) + raise ArgumentError.new + end + rescue ArgumentError + end + assert_equal "bar", @cache.read(key) + end + Time.stub(:now, time + 91) do + assert_nil @cache.read(key) + end + end + + def test_race_condition_protection + time = Time.now + key = SecureRandom.uuid + @cache.write(key, "bar", expires_in: 60) + Time.stub(:now, time + 61) do + result = @cache.fetch(key, race_condition_ttl: 10) do + assert_equal "bar", @cache.read(key) + "baz" + end + assert_equal "baz", result + end + end + + def test_fetch_multi_race_condition_protection + time = Time.now + key = SecureRandom.uuid + other_key = SecureRandom.uuid + @cache.write(key, "foo", expires_in: 60) + @cache.write(other_key, "bar", expires_in: 100) + Time.stub(:now, time + 71) do + result = @cache.fetch_multi(key, other_key, race_condition_ttl: 10) do + assert_nil @cache.read(key) + assert_equal "bar", @cache.read(other_key) + "baz" + end + assert_equal({ key => "baz", other_key => "bar" }, result) + end + end + + def test_absurd_key_characters + absurd_key = "#/:*(<+=> )&$%@?;'\"\'`~-" + assert @cache.write(absurd_key, "1", raw: true) + assert_equal "1", @cache.read(absurd_key, raw: true) + assert_equal "1", @cache.fetch(absurd_key, raw: true) + assert @cache.delete(absurd_key) + assert_equal "2", @cache.fetch(absurd_key, raw: true) { "2" } + assert_equal 3, @cache.increment(absurd_key) + assert_equal 2, @cache.decrement(absurd_key) + end + + def test_really_long_keys + key = SecureRandom.alphanumeric * 2048 + assert @cache.write(key, "bar") + assert_equal "bar", @cache.read(key) + assert_equal "bar", @cache.fetch(key) + assert_nil @cache.read("#{key}x") + assert_equal({ key => "bar" }, @cache.read_multi(key)) + end + + def test_cache_hit_instrumentation + key = "test_key" + @events = [] + ActiveSupport::Notifications.subscribe("cache_read.active_support") { |event| @events << event } + assert @cache.write(key, "1", raw: true) + assert @cache.fetch(key, raw: true) { } + assert_equal 1, @events.length + assert_equal "cache_read.active_support", @events[0].name + assert_equal :fetch, @events[0].payload[:super_operation] + assert @events[0].payload[:hit] + ensure + ActiveSupport::Notifications.unsubscribe "cache_read.active_support" + end + + def test_cache_miss_instrumentation + @events = [] + ActiveSupport::Notifications.subscribe(/^cache_(.*)\.active_support$/) { |event| @events << event } + assert_not @cache.fetch(SecureRandom.uuid) { } + assert_equal 3, @events.length + assert_equal "cache_read.active_support", @events[0].name + assert_equal "cache_generate.active_support", @events[1].name + assert_equal "cache_write.active_support", @events[2].name + assert_equal :fetch, @events[0].payload[:super_operation] + assert_not @events[0].payload[:hit] + ensure + ActiveSupport::Notifications.unsubscribe "cache_read.active_support" + end + + def test_setting_options_in_fetch_block_does_not_change_cache_options + key = SecureRandom.uuid + + assert_no_changes -> { @cache.options.dup } do + @cache.fetch(key) do |_key, options| + options.expires_in = 5.minutes + "bar" + end + end + end + + private + def with_raise_on_invalid_cache_expiration_time(new_value, &block) + old_value = ActiveSupport::Cache::Store.raise_on_invalid_cache_expiration_time + ActiveSupport::Cache::Store.raise_on_invalid_cache_expiration_time = new_value + + yield + ensure + ActiveSupport::Cache::Store.raise_on_invalid_cache_expiration_time = old_value + end + + def capture_logs(&block) + old_logger = ActiveSupport::Cache::Store.logger + log = StringIO.new + ActiveSupport::Cache::Store.logger = ActiveSupport::Logger.new(log) + begin + yield + log.string + ensure + ActiveSupport::Cache::Store.logger = old_logger + end + end +end diff --git a/test/unit/behaviors_rails_8_0/cache_store_coder_behavior.rb b/test/unit/behaviors_rails_8_0/cache_store_coder_behavior.rb new file mode 100644 index 0000000..461d868 --- /dev/null +++ b/test/unit/behaviors_rails_8_0/cache_store_coder_behavior.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module CacheStoreCoderBehavior + class SpyCoder + attr_reader :dumped_entries, :loaded_entries, :dump_compressed_entries + + def initialize + @dumped_entries = [] + @loaded_entries = [] + @dump_compressed_entries = [] + end + + def dump(entry) + @dumped_entries << entry + Marshal.dump(entry) + end + + def load(payload) + entry = Marshal.load(payload) + @loaded_entries << entry + entry + end + + def dump_compressed(entry, threshold) + if threshold == 0 + @dump_compressed_entries << entry + Marshal.dump(entry) + else + dump(entry) + end + end + end + + def test_coder_receive_the_entry_on_write + coder = SpyCoder.new + @store = lookup_store(coder: coder) + @store.write("foo", "bar") + assert_equal 1, coder.dumped_entries.size + entry = coder.dumped_entries.first + assert_instance_of ActiveSupport::Cache::Entry, entry + assert_equal "bar", entry.value + end + + def test_coder_receive_the_entry_on_read + coder = SpyCoder.new + @store = lookup_store(coder: coder) + @store.write("foo", "bar") + @store.read("foo") + assert_equal 1, coder.loaded_entries.size + entry = coder.loaded_entries.first + assert_instance_of ActiveSupport::Cache::Entry, entry + assert_equal "bar", entry.value + end + + def test_coder_receive_the_entry_on_read_multi + coder = SpyCoder.new + @store = lookup_store(coder: coder) + @store.write_multi({ "foo" => "bar", "egg" => "spam" }) + @store.read_multi("foo", "egg") + assert_equal 2, coder.loaded_entries.size + entry = coder.loaded_entries.first + assert_instance_of ActiveSupport::Cache::Entry, entry + assert_equal "bar", entry.value + + entry = coder.loaded_entries[1] + assert_instance_of ActiveSupport::Cache::Entry, entry + assert_equal "spam", entry.value + end + + def test_coder_receive_the_entry_on_write_multi + coder = SpyCoder.new + @store = lookup_store(coder: coder) + @store.write_multi({ "foo" => "bar", "egg" => "spam" }) + assert_equal 2, coder.dumped_entries.size + entry = coder.dumped_entries.first + assert_instance_of ActiveSupport::Cache::Entry, entry + assert_equal "bar", entry.value + + entry = coder.dumped_entries[1] + assert_instance_of ActiveSupport::Cache::Entry, entry + assert_equal "spam", entry.value + end + + def test_coder_does_not_receive_the_entry_on_read_miss + coder = SpyCoder.new + @store = lookup_store(coder: coder) + @store.read("foo") + assert_equal 0, coder.loaded_entries.size + end + + def test_nil_coder_bypasses_serialization + @store = lookup_store(coder: nil) + entry = ActiveSupport::Cache::Entry.new("value") + assert_same entry, @store.send(:serialize_entry, entry) + end + + def test_coder_is_used_during_handle_expired_entry_when_expired + coder = SpyCoder.new + @store = lookup_store(coder: coder) + @store.write("foo", "bar", expires_in: 1.second) + assert_equal 0, coder.loaded_entries.size + assert_equal 1, coder.dumped_entries.size + + travel_to(2.seconds.from_now) do + val = @store.fetch( + "foo", + race_condition_ttl: 5, + compress: true, + compress_threshold: 0 + ) { "baz" } + assert_equal "baz", val + assert_equal 1, coder.loaded_entries.size # 1 read in fetch + assert_equal "bar", coder.loaded_entries.first.value + assert_equal 1, coder.dumped_entries.size # did not change from original write + assert_equal 2, coder.dump_compressed_entries.size # 1 write the expired entry handler, 1 in fetch + assert_equal "bar", coder.dump_compressed_entries.first.value + assert_equal "baz", coder.dump_compressed_entries.last.value + end + end +end diff --git a/test/unit/behaviors_rails_8_0/cache_store_compression_behavior.rb b/test/unit/behaviors_rails_8_0/cache_store_compression_behavior.rb new file mode 100644 index 0000000..9aee159 --- /dev/null +++ b/test/unit/behaviors_rails_8_0/cache_store_compression_behavior.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "active_support/core_ext/numeric/bytes" +require "active_support/core_ext/object/with" + +module CacheStoreCompressionBehavior + extend ActiveSupport::Concern + + included do + test "compression works with cache format version 7.0 (using Marshal70WithFallback)" do + @cache = with_format(7.0) { lookup_store(compress: true) } + assert_compression true + end + + test "compression works with cache format version >= 7.1 (using Cache::Coder)" do + @cache = with_format(7.1) { lookup_store(compress: true) } + assert_compression true + end + + test "compression is disabled with custom coder" do + @cache = with_format(7.1) { lookup_store(coder: Marshal) } + assert_compression false + end + + test "compression works with custom serializer" do + @cache = with_format(7.1) { lookup_store(compress: true, serializer: Marshal) } + assert_compression true + end + + test "compression by default" do + @cache = lookup_store + assert_compression !compression_always_disabled_by_default? + end + + test "compression can be disabled" do + @cache = lookup_store(compress: false) + assert_compression false + end + + test ":compress method option overrides initializer option" do + @cache = lookup_store(compress: true) + assert_compression false, with: { compress: false } + + @cache = lookup_store(compress: false) + assert_compression true, with: { compress: true } + end + + test "low :compress_threshold triggers compression" do + @cache = lookup_store(compress: true, compress_threshold: 1) + assert_compression :all + end + + test "high :compress_threshold inhibits compression" do + @cache = lookup_store(compress: true, compress_threshold: 1.megabyte) + assert_compression false + end + + test ":compress_threshold method option overrides initializer option" do + @cache = lookup_store(compress: true, compress_threshold: 1) + assert_compression false, with: { compress_threshold: 1.megabyte } + + @cache = lookup_store(compress: true, compress_threshold: 1.megabyte) + assert_compression :all, with: { compress_threshold: 1 } + end + + test "compression ignores nil" do + assert_not_compress nil + assert_not_compress nil, with: { compress: true, compress_threshold: 1 } + end + + test "compression ignores incompressible data" do + assert_not_compress "", with: { compress: true, compress_threshold: 1 } + assert_not_compress [*0..127].pack("C*"), with: { compress: true, compress_threshold: 1 } + end + + test "compressor can be specified" do + lossy_compressor = Module.new do + def self.deflate(dumped) + "yolo" + end + + def self.inflate(compressed) + Marshal.dump("lossy!") if compressed == "yolo" + end + end + + @cache = with_format(7.1) do + lookup_store(compress: true, compressor: lossy_compressor, serializer: Marshal) + end + key = SecureRandom.uuid + + @cache.write(key, LARGE_OBJECT) + assert_equal "lossy!", @cache.read(key) + end + + test "compressor can be nil" do + @cache = with_format(7.1) { lookup_store(compressor: nil) } + assert_compression false + end + + test "specifying a compressor raises when cache format version < 7.1" do + with_format(7.0) do + assert_raises ArgumentError, match: /compressor/i do + lookup_store(compressor: Zlib) + end + end + end + + test "specifying a compressor raises when also specifying a coder" do + with_format(7.1) do + assert_raises ArgumentError, match: /compressor/i do + lookup_store(compressor: Zlib, coder: Marshal) + end + end + end + end + + private + # Use strings that are guaranteed to compress well, so we can easily tell if + # the compression kicked in or not. + SMALL_STRING = "0" * 100 + LARGE_STRING = "0" * 2.kilobytes + + SMALL_OBJECT = { data: SMALL_STRING } + LARGE_OBJECT = { data: LARGE_STRING } + + def with_format(format_version, &block) + ActiveSupport::Cache.with(format_version: format_version, &block) + end + + def assert_compress(value, **options) + assert_operator compute_entry_size_reduction(value, **options), :>, 0 + end + + def assert_not_compress(value, **options) + assert_equal 0, compute_entry_size_reduction(value, **options) + end + + def assert_compression(compress, **options) + if compress == :all + assert_compress SMALL_STRING, **options + assert_compress SMALL_OBJECT, **options + else + assert_not_compress SMALL_STRING, **options + assert_not_compress SMALL_OBJECT, **options + end + + if compress + assert_compress LARGE_STRING, **options + assert_compress LARGE_OBJECT, **options + else + assert_not_compress LARGE_STRING, **options + assert_not_compress LARGE_OBJECT, **options + end + end + + def compute_entry_size_reduction(value, with: {}) + entry = ActiveSupport::Cache::Entry.new(value) + + uncompressed = @cache.send(:serialize_entry, entry, **with, compress: false) + actual = @cache.send(:serialize_entry, entry, **with) + + uncompressed.bytesize - actual.bytesize + end + + def compression_always_disabled_by_default? + false + end +end diff --git a/test/unit/behaviors_rails_8_0/cache_store_format_version_behavior.rb b/test/unit/behaviors_rails_8_0/cache_store_format_version_behavior.rb new file mode 100644 index 0000000..9f9d80e --- /dev/null +++ b/test/unit/behaviors_rails_8_0/cache_store_format_version_behavior.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/with" + +module CacheStoreFormatVersionBehavior + extend ActiveSupport::Concern + + FORMAT_VERSION_SIGNATURES = { + 7.0 => [ + "\x00\x04\x08[".b, # "\x00" + Marshal.dump(entry.pack) + "\x01\x78".b, # "\x01" + Zlib::Deflate.deflate(...) + ], + 7.1 => [ + "\x00\x11\x01".b, # ActiveSupport::Cache::Coder#dump + "\x00\x11\x81".b, # ActiveSupport::Cache::Coder#dump_compressed + ], + } + + FORMAT_VERSIONS = FORMAT_VERSION_SIGNATURES.keys + + included do + test "format version affects default coder" do + coders = FORMAT_VERSIONS.map do |format_version| + with_format(format_version) do + lookup_store.instance_variable_get(:@coder) + end + end + + assert_equal coders, coders.uniq + end + + test "invalid format version raises" do + with_format(0) do + assert_raises do + lookup_store + end + end + end + + FORMAT_VERSION_SIGNATURES.each do |format_version, (uncompressed_signature, compressed_signature)| + test "format version #{format_version.inspect} uses correct signature for uncompressed entries" do + serialized = with_format(format_version) do + lookup_store.send(:serialize_entry, ActiveSupport::Cache::Entry.new(["value"] * 100)) + end + + if serialized.is_a?(String) + assert_operator serialized, :start_with?, uncompressed_signature + end + end + + test "format version #{format_version.inspect} uses correct signature for compressed entries" do + serialized = with_format(format_version) do + lookup_store.send(:serialize_entry, ActiveSupport::Cache::Entry.new(["value"] * 100), compress_threshold: 1) + end + + if serialized.is_a?(String) + assert_operator serialized, :start_with?, compressed_signature + end + end + + test "Marshal undefined class/module deserialization error with #{format_version} format" do + key = "marshal-#{rand}" + self.class.const_set(:RemovedConstant, Class.new) + @store = with_format(format_version) { lookup_store } + @store.write(key, self.class::RemovedConstant.new) + assert_instance_of self.class::RemovedConstant, @store.read(key) + + self.class.send(:remove_const, :RemovedConstant) + assert_nil @store.read(key) + assert_equal false, @store.exist?(key) + ensure + self.class.send(:remove_const, :RemovedConstant) rescue nil + end + + test "Compressed Marshal undefined class/module deserialization error with #{format_version} format" do + key = "marshal-#{rand}" + self.class.const_set(:RemovedConstant, Class.new) + @store = with_format(format_version) { lookup_store } + @store.write(key, self.class::RemovedConstant.new, compress: true, compress_threshold: 1) + assert_instance_of self.class::RemovedConstant, @store.read(key) + + self.class.send(:remove_const, :RemovedConstant) + assert_nil @store.read(key) + assert_equal({}, @store.read_multi(key)) + assert_equal("new-value", @store.fetch(key) { "new-value" }) + ensure + self.class.send(:remove_const, :RemovedConstant) rescue nil + end + end + + FORMAT_VERSIONS.product(FORMAT_VERSIONS) do |read_version, write_version| + test "format version #{read_version.inspect} can read #{write_version.inspect} entries" do + key = SecureRandom.uuid + + with_format(write_version) do + lookup_store.write(key, "value for #{key}") + end + + with_format(read_version) do + assert_equal "value for #{key}", lookup_store.read(key) + end + end + + test "format version #{read_version.inspect} can read #{write_version.inspect} entries with compression" do + key = SecureRandom.uuid + + with_format(write_version) do + lookup_store(compress_threshold: 1).write(key, key * 10) + end + + with_format(read_version) do + assert_equal key * 10, lookup_store.read(key) + end + end + end + end + + private + def with_format(format_version, &block) + ActiveSupport::Cache.with(format_version: format_version, &block) + end +end diff --git a/test/unit/behaviors_rails_8_0/cache_store_serializer_behavior.rb b/test/unit/behaviors_rails_8_0/cache_store_serializer_behavior.rb new file mode 100644 index 0000000..112603e --- /dev/null +++ b/test/unit/behaviors_rails_8_0/cache_store_serializer_behavior.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/with" + +module CacheStoreSerializerBehavior + extend ActiveSupport::Concern + + included do + test "serializer can be specified" do + serializer = Module.new do + def self.dump(value) + value.class.name + end + + def self.load(dumped) + Object.const_get(dumped) + end + end + + @cache = with_format(7.1) { lookup_store(serializer: serializer) } + key = "key#{rand}" + + @cache.write(key, 123) + assert_equal Integer, @cache.read(key) + end + + test "serializer can be :message_pack" do + @cache = with_format(7.1) { lookup_store(serializer: :message_pack) } + key = "key#{rand}" + + @cache.write(key, 123) + assert_equal 123, @cache.read(key) + + assert_raises ActiveSupport::MessagePack::UnserializableObjectError do + @cache.write(key, Object.new) + end + end + + test "specifying a serializer raises when also specifying a coder" do + with_format(7.1) do + assert_raises ArgumentError, match: /serializer/i do + lookup_store(serializer: Marshal, coder: Marshal) + end + end + end + end +end diff --git a/test/unit/behaviors_rails_8_0/cache_store_version_behavior.rb b/test/unit/behaviors_rails_8_0/cache_store_version_behavior.rb new file mode 100644 index 0000000..b781404 --- /dev/null +++ b/test/unit/behaviors_rails_8_0/cache_store_version_behavior.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module CacheStoreVersionBehavior + ModelWithKeyAndVersion = Struct.new(:cache_key, :cache_version) + + def test_fetch_with_right_version_should_hit + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + + @cache.fetch(key, version: 1) { value } + assert_equal value, @cache.read(key, version: 1) + end + + def test_fetch_with_wrong_version_should_miss + key = SecureRandom.uuid + + @cache.fetch(key, version: 1) { SecureRandom.alphanumeric } + assert_nil @cache.read(key, version: 2) + end + + def test_read_with_right_version_should_hit + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + + @cache.write(key, value, version: 1) + assert_equal value, @cache.read(key, version: 1) + end + + def test_read_with_wrong_version_should_miss + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + + @cache.write(key, value, version: 1) + assert_nil @cache.read(key, version: 2) + end + + def test_exist_with_right_version_should_be_true + key = SecureRandom.uuid + + @cache.write(key, SecureRandom.alphanumeric, version: 1) + assert @cache.exist?(key, version: 1) + end + + def test_exist_with_wrong_version_should_be_false + key = SecureRandom.uuid + + @cache.write(key, SecureRandom.alphanumeric, version: 1) + assert_not @cache.exist?(key, version: 2) + end + + def test_reading_and_writing_with_model_supporting_cache_version + model_name = SecureRandom.alphanumeric + + m1v1 = ModelWithKeyAndVersion.new("#{model_name}/1", 1) + m1v2 = ModelWithKeyAndVersion.new("#{model_name}/1", 2) + + value = SecureRandom.alphanumeric + + @cache.write(m1v1, value) + assert_equal value, @cache.read(m1v1) + assert_nil @cache.read(m1v2) + end + + def test_reading_and_writing_with_model_supporting_cache_version_using_nested_key + model_name = SecureRandom.alphanumeric + + m1v1 = ModelWithKeyAndVersion.new("#{model_name}/1", 1) + m1v2 = ModelWithKeyAndVersion.new("#{model_name}/1", 2) + + value = SecureRandom.alphanumeric + + @cache.write([ "something", m1v1 ], value) + assert_equal value, @cache.read([ "something", m1v1 ]) + assert_nil @cache.read([ "something", m1v2 ]) + end + + def test_fetching_with_model_supporting_cache_version + model_name = SecureRandom.alphanumeric + + m1v1 = ModelWithKeyAndVersion.new("#{model_name}/1", 1) + m1v2 = ModelWithKeyAndVersion.new("#{model_name}/1", 2) + + value = SecureRandom.alphanumeric + other_value = SecureRandom.alphanumeric + + @cache.fetch(m1v1) { value } + assert_equal value, @cache.fetch(m1v1) { other_value } + assert_equal other_value, @cache.fetch(m1v2) { other_value } + end + + def test_exist_with_model_supporting_cache_version + model_name = SecureRandom.alphanumeric + + m1v1 = ModelWithKeyAndVersion.new("#{model_name}/1", 1) + m1v2 = ModelWithKeyAndVersion.new("#{model_name}/1", 2) + + value = SecureRandom.alphanumeric + + @cache.write(m1v1, value) + assert @cache.exist?(m1v1) + assert_not @cache.fetch(m1v2) + end + + def test_fetch_multi_with_model_supporting_cache_version + model_name = SecureRandom.alphanumeric + + m1v1 = ModelWithKeyAndVersion.new("#{model_name}/1", 1) + m2v1 = ModelWithKeyAndVersion.new("#{model_name}/2", 1) + m2v2 = ModelWithKeyAndVersion.new("#{model_name}/2", 2) + + first_fetch_values = @cache.fetch_multi(m1v1, m2v1) { |m| m.cache_key } + second_fetch_values = @cache.fetch_multi(m1v1, m2v2) { |m| m.cache_key + " 2nd" } + + assert_equal({ m1v1 => "#{model_name}/1", m2v1 => "#{model_name}/2" }, first_fetch_values) + assert_equal({ m1v1 => "#{model_name}/1", m2v2 => "#{model_name}/2 2nd" }, second_fetch_values) + end + + def test_version_is_normalized + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + + @cache.write(key, value, version: 1) + assert_equal value, @cache.read(key, version: "1") + end +end diff --git a/test/unit/behaviors_rails_8_0/connection_pool_behavior.rb b/test/unit/behaviors_rails_8_0/connection_pool_behavior.rb new file mode 100644 index 0000000..cb59c04 --- /dev/null +++ b/test/unit/behaviors_rails_8_0/connection_pool_behavior.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module ConnectionPoolBehavior + def test_connection_pool + Thread.report_on_exception, original_report_on_exception = false, Thread.report_on_exception + + threads = [] + + emulating_latency do + cache = ActiveSupport::Cache.lookup_store(*store, { pool: { size: 2, timeout: 1 } }.merge(store_options)) + cache.read("foo") + + assert_raises Timeout::Error do + # One of the three threads will fail in 1 second because our pool size + # is only two. + 3.times do + threads << Thread.new do + cache.read("latency") + end + end + + threads.each(&:join) + end + ensure + threads.each(&:kill) + end + ensure + Thread.report_on_exception = original_report_on_exception + end + + def test_no_connection_pool + threads = [] + + emulating_latency do + cache = ActiveSupport::Cache.lookup_store(*store, store_options.merge(pool: false)) + + assert_nothing_raised do + # Default connection pool size is 5, assuming 10 will make sure that + # the connection pool isn't used at all. + 10.times do + threads << Thread.new do + cache.read("latency") + end + end + + threads.each(&:join) + end + ensure + threads.each(&:kill) + end + end + + private + def store_options; {}; end +end diff --git a/test/unit/behaviors_rails_8_0/encoded_key_cache_behavior.rb b/test/unit/behaviors_rails_8_0/encoded_key_cache_behavior.rb new file mode 100644 index 0000000..4a805aa --- /dev/null +++ b/test/unit/behaviors_rails_8_0/encoded_key_cache_behavior.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# https://rails.lighthouseapp.com/projects/8994/tickets/6225-memcachestore-cant-deal-with-umlauts-and-special-characters +# The error is caused by character encodings that can't be compared with ASCII-8BIT regular expressions and by special +# characters like the umlaut in UTF-8. +module EncodedKeyCacheBehavior + Encoding.list.each do |encoding| + define_method "test_#{encoding.name.underscore}_encoded_values" do + key = (+"foo_#{encoding.name.underscore}").force_encoding(encoding) + assert @cache.write(key, "1", raw: true) + assert_equal "1", @cache.read(key, raw: true) + assert_equal "1", @cache.fetch(key, raw: true) + assert @cache.delete(key) + assert_equal "2", @cache.fetch(key, raw: true) { "2" } + assert_equal 3, @cache.increment(key) + assert_equal 2, @cache.decrement(key) + end + end + + def test_common_utf8_values + key = (+"\xC3\xBCmlaut").force_encoding(Encoding::UTF_8) + assert @cache.write(key, "1", raw: true) + assert_equal "1", @cache.read(key, raw: true) + assert_equal "1", @cache.fetch(key, raw: true) + assert @cache.delete(key) + assert_equal "2", @cache.fetch(key, raw: true) { "2" } + assert_equal 3, @cache.increment(key) + assert_equal 2, @cache.decrement(key) + end + + def test_retains_encoding + key = (+"\xC3\xBCmlaut").force_encoding(Encoding::UTF_8) + assert @cache.write(key, "1", raw: true) + assert_equal Encoding::UTF_8, key.encoding + end +end diff --git a/test/unit/behaviors_rails_8_0/failure_raising_behavior.rb b/test/unit/behaviors_rails_8_0/failure_raising_behavior.rb new file mode 100644 index 0000000..7afd425 --- /dev/null +++ b/test/unit/behaviors_rails_8_0/failure_raising_behavior.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module FailureRaisingBehavior + def test_fetch_read_failure_raises + key = SecureRandom.uuid + @cache.write(key, SecureRandom.alphanumeric) + + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.fetch(key) + end + end + end + + def test_fetch_with_block_read_failure_raises + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + @cache.write(key, value) + + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.fetch(key) { SecureRandom.alphanumeric } + end + end + + assert_equal value, @cache.read(key) + end + + def test_read_failure_raises + key = SecureRandom.uuid + @cache.write(key, SecureRandom.alphanumeric) + + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.read(key) + end + end + end + + def test_read_multi_failure_raises + key = SecureRandom.uuid + other_key = SecureRandom.uuid + @cache.write_multi( + key => SecureRandom.alphanumeric, + other_key => SecureRandom.alphanumeric + ) + + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.read_multi(key, other_key) + end + end + end + + def test_write_failure_raises + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.write(SecureRandom.uuid, SecureRandom.alphanumeric) + end + end + end + + def test_write_multi_failure_raises + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.write_multi( + SecureRandom.uuid => SecureRandom.alphanumeric, + SecureRandom.uuid => SecureRandom.alphanumeric + ) + end + end + end + + def test_fetch_multi_failure_raises + key = SecureRandom.uuid + other_key = SecureRandom.uuid + @cache.write_multi( + key => SecureRandom.alphanumeric, + other_key => SecureRandom.alphanumeric + ) + + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.fetch_multi(key, other_key) { |k| "unavailable" } + end + end + end + + def test_delete_failure_raises + key = SecureRandom.uuid + @cache.write(key, SecureRandom.alphanumeric) + + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.delete(key) + end + end + end + + def test_exist_failure_raises + key = SecureRandom.uuid + @cache.write(key, SecureRandom.alphanumeric) + + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.exist?(key) + end + end + end + + def test_increment_failure_raises + key = SecureRandom.uuid + @cache.write(key, 1, raw: true) + + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.increment(key) + end + end + end + + def test_decrement_failure_raises + key = SecureRandom.uuid + @cache.write(key, 1, raw: true) + + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.decrement(key) + end + end + end + + def test_clear_failure_returns_nil + assert_raise ActiveRecord::StatementTimeout do + emulating_unavailability do |cache| + cache.clear + end + end + end +end diff --git a/test/unit/behaviors_rails_8_0/failure_safety_behavior.rb b/test/unit/behaviors_rails_8_0/failure_safety_behavior.rb new file mode 100644 index 0000000..1bf9807 --- /dev/null +++ b/test/unit/behaviors_rails_8_0/failure_safety_behavior.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +module FailureSafetyBehavior + def test_fetch_read_failure_returns_nil + key = SecureRandom.uuid + @cache.write(key, SecureRandom.alphanumeric) + + emulating_unavailability do |cache| + assert_nil cache.fetch(key) + end + end + + def test_fetch_read_failure_does_not_attempt_to_write + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + @cache.write(key, value) + + emulating_unavailability do |cache| + val = cache.fetch(key) { "1" } + + ## + # Though the `write` part of fetch fails for the same reason + # `read` will, the block result is still executed and returned. + assert_equal "1", val + end + + assert_equal value, @cache.read(key) + end + + def test_read_failure_returns_nil + key = SecureRandom.uuid + @cache.write(key, SecureRandom.alphanumeric) + + emulating_unavailability do |cache| + assert_nil cache.read(key) + end + end + + def test_read_multi_failure_returns_empty_hash + key = SecureRandom.uuid + other_key = SecureRandom.uuid + @cache.write_multi( + key => SecureRandom.alphanumeric, + other_key => SecureRandom.alphanumeric + ) + + emulating_unavailability do |cache| + assert_equal Hash.new, cache.read_multi(key, other_key) + end + end + + def test_write_failure_returns_nil + key = SecureRandom.uuid + emulating_unavailability do |cache| + assert_nil cache.write(key, SecureRandom.alphanumeric) + end + end + + def test_write_multi_failure_not_raises + emulating_unavailability do |cache| + assert_nothing_raised do + cache.write_multi( + SecureRandom.uuid => SecureRandom.alphanumeric, + SecureRandom.uuid => SecureRandom.alphanumeric + ) + end + end + end + + def test_fetch_multi_failure_returns_fallback_results + key = SecureRandom.uuid + other_key = SecureRandom.uuid + @cache.write_multi( + key => SecureRandom.alphanumeric, + other_key => SecureRandom.alphanumeric + ) + + + emulating_unavailability do |cache| + fetched = cache.fetch_multi(key, other_key) { |k| "unavailable" } + assert_equal Hash[key => "unavailable", other_key => "unavailable"], fetched + end + end + + def test_delete_failure_returns_false + key = SecureRandom.uuid + @cache.write(key, SecureRandom.alphanumeric) + + emulating_unavailability do |cache| + assert_equal false, cache.delete(key) + end + end + + def test_delete_multi_failure_returns_zero + key = SecureRandom.uuid + other_key = SecureRandom.uuid + @cache.write_multi( + key => SecureRandom.alphanumeric, + other_key => SecureRandom.alphanumeric + ) + + emulating_unavailability do |cache| + assert_equal 0, cache.delete_multi([key, other_key]) + end + end + + def test_exist_failure_returns_false + key = SecureRandom.uuid + @cache.write(key, SecureRandom.alphanumeric) + + emulating_unavailability do |cache| + assert_not cache.exist?(key) + end + end + + def test_increment_failure_returns_nil + key = SecureRandom.uuid + @cache.write(key, 1, raw: true) + + emulating_unavailability do |cache| + assert_nil cache.increment(key) + end + end + + def test_decrement_failure_returns_nil + key = SecureRandom.uuid + @cache.write(key, 1, raw: true) + + emulating_unavailability do |cache| + assert_nil cache.decrement(key) + end + end + + def test_clear_failure_returns_nil + emulating_unavailability do |cache| + assert_nil cache.clear + end + end +end diff --git a/test/unit/behaviors_rails_8_0/local_cache_behavior.rb b/test/unit/behaviors_rails_8_0/local_cache_behavior.rb new file mode 100644 index 0000000..f7ff67e --- /dev/null +++ b/test/unit/behaviors_rails_8_0/local_cache_behavior.rb @@ -0,0 +1,322 @@ +# frozen_string_literal: true + +module LocalCacheBehavior + def test_instrumentation_with_local_cache + key = SecureRandom.uuid + events = with_instrumentation "write" do + @cache.write(key, SecureRandom.uuid) + end + assert_equal @cache.class.name, events[0].payload[:store] + + @cache.with_local_cache do + events = with_instrumentation "read" do + @cache.read(key) + @cache.read(key) + end + + expected = [@cache.class.name, @cache.send(:local_cache).class.name] + assert_equal expected, events.map { |p| p.payload[:store] } + end + end + + def test_local_writes_are_persistent_on_the_remote_cache + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + retval = @cache.with_local_cache do + @cache.write(key, value) + end + assert retval + assert_equal value, @cache.read(key) + end + + def test_clear_also_clears_local_cache + key = SecureRandom.uuid + @cache.with_local_cache do + @cache.write(key, SecureRandom.alphanumeric) + @cache.clear + assert_nil @cache.read(key) + end + + assert_nil @cache.read(key) + end + + def test_clear_with_nil_options + key = SecureRandom.uuid + @cache.with_local_cache do + @cache.write(key, SecureRandom.alphanumeric) + @cache.clear(nil) + assert_nil @cache.read(key) + end + + assert_nil @cache.read(key) + end + + def test_cleanup_clears_local_cache_but_not_remote_cache + begin + @cache.cleanup + rescue NotImplementedError + pass + return # Not implementing cleanup is valid + end + + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + other_value = SecureRandom.alphanumeric + + @cache.with_local_cache do + @cache.write(key, value) + assert_equal value, @cache.read(key) + + @cache.send(:bypass_local_cache) { @cache.write(key, other_value) } + assert_equal value, @cache.read(key) + + @cache.cleanup + assert_equal other_value, @cache.read(key) + end + end + + def test_local_cache_of_write + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + @cache.with_local_cache do + @cache.write(key, value) + @peek.delete(key) + assert_equal value, @cache.read(key) + end + end + + def test_local_cache_of_read_returns_a_copy_of_the_entry + key = SecureRandom.alphanumeric.to_sym + value = SecureRandom.alphanumeric + @cache.with_local_cache do + @cache.write(key, type: value) + local_value = @cache.read(key) + assert_equal(value, local_value.delete(:type)) + assert_equal({ type: value }, @cache.read(key)) + end + end + + def test_local_cache_of_read + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + @cache.write(key, value) + @cache.with_local_cache do + assert_equal value, @cache.read(key) + end + end + + def test_local_cache_of_read_nil + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + @cache.with_local_cache do + assert_nil @cache.read(key) + @cache.send(:bypass_local_cache) { @cache.write(key, value) } + assert_nil @cache.read(key) + end + end + + def test_local_cache_fetch + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + @cache.with_local_cache do + @cache.send(:local_cache).write_entry(key, value) + assert_equal value, @cache.send(:local_cache).fetch_entry(key) + end + end + + def test_local_cache_of_write_nil + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + @cache.with_local_cache do + assert @cache.write(key, nil) + assert_nil @cache.read(key) + @peek.write(key, value) + assert_nil @cache.read(key) + end + end + + def test_local_cache_of_write_with_unless_exist + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + @cache.with_local_cache do + @cache.write(key, value) + @cache.write(key, SecureRandom.alphanumeric, unless_exist: true) + assert_equal @peek.read(key), @cache.read(key) + end + end + + def test_local_cache_of_delete + key = SecureRandom.uuid + @cache.with_local_cache do + @cache.write(key, SecureRandom.alphanumeric) + @cache.delete(key) + assert_nil @cache.read(key) + end + end + + def test_local_cache_of_delete_matched + begin + @cache.delete_matched("*") + rescue NotImplementedError + return # Not implementing delete_matched is valid + end + + prefix = SecureRandom.alphanumeric + key = "#{prefix}#{SecureRandom.uuid}" + other_key = "#{prefix}#{SecureRandom.uuid}" + third_key = SecureRandom.uuid + value = SecureRandom.alphanumeric + @cache.with_local_cache do + @cache.write(key, SecureRandom.alphanumeric) + @cache.write(other_key, SecureRandom.alphanumeric) + @cache.write(third_key, value) + @cache.delete_matched("#{prefix}*") + assert_not @cache.exist?(key) + assert_not @cache.exist?(other_key) + assert_equal value, @cache.read(third_key) + end + end + + def test_local_cache_of_exist + key = SecureRandom.uuid + @cache.with_local_cache do + @cache.write(key, SecureRandom.alphanumeric) + @peek.delete(key) + assert @cache.exist?(key) + end + end + + def test_local_cache_of_increment + key = SecureRandom.uuid + @cache.with_local_cache do + @cache.write(key, 1, raw: true) + @peek.write(key, 2, raw: true) + + @cache.increment(key) + + expected = @peek.read(key, raw: true) + assert_equal 3, Integer(expected) + assert_equal expected, @cache.read(key, raw: true) + end + end + + def test_local_cache_of_decrement + key = SecureRandom.uuid + @cache.with_local_cache do + @cache.write(key, 1, raw: true) + @peek.write(key, 3, raw: true) + + @cache.decrement(key) + + expected = @peek.read(key, raw: true) + assert_equal 2, Integer(expected) + assert_equal expected, @cache.read(key, raw: true) + end + end + + def test_local_cache_of_fetch_multi + key = SecureRandom.uuid + other_key = SecureRandom.uuid + @cache.with_local_cache do + @cache.fetch_multi(key, other_key) { |_key| true } + @peek.delete(key) + @peek.delete(other_key) + assert_equal true, @cache.read(key) + assert_equal true, @cache.read(other_key) + end + end + + def test_local_cache_of_read_multi + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + other_key = SecureRandom.uuid + other_value = SecureRandom.alphanumeric + @cache.with_local_cache do + @cache.write(key, value, raw: true) + @peek.write(other_key, other_value, raw: true) + values = @cache.read_multi(key, other_key, raw: true) + assert_equal value, @cache.read(key, raw: true) + assert_equal other_value, @cache.read(other_key, raw: true) + assert_equal value, values[key] + assert_equal other_value, values[other_key] + end + end + + def test_local_cache_of_read_multi_prioritizes_local_entries + key = "key#{rand}" + @cache.with_local_cache do + @cache.write(key, "foo") + @cache.send(:bypass_local_cache) { @cache.write(key, "bar") } + + assert_equal({ key => "foo" }, @cache.read_multi(key)) + end + end + + def test_initial_object_mutation_after_write + key = SecureRandom.uuid + @cache.with_local_cache do + initial = +"bar" + @cache.write(key, initial) + initial << "baz" + assert_equal "bar", @cache.read(key) + end + end + + def test_initial_object_mutation_after_fetch + key = SecureRandom.uuid + @cache.with_local_cache do + initial = +"bar" + @cache.fetch(key) { initial } + initial << "baz" + assert_equal "bar", @cache.read(key) + assert_equal "bar", @cache.fetch(key) + end + end + + def test_middleware + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + app = lambda { |env| + result = @cache.write(key, value) + assert_equal value, @cache.read(key) # make sure 'foo' was written + assert result + [200, {}, []] + } + app = @cache.middleware.new(app) + app.call({}) + end + + def test_local_race_condition_protection + key = SecureRandom.uuid + value = SecureRandom.alphanumeric + other_value = SecureRandom.alphanumeric + @cache.with_local_cache do + time = Time.now + @cache.write(key, value, expires_in: 60) + Time.stub(:now, time + 61) do + result = @cache.fetch(key, race_condition_ttl: 10) do + assert_equal value, @cache.read(key) + other_value + end + assert_equal other_value, result + end + end + end + + def test_local_cache_should_read_and_write_false + key = SecureRandom.uuid + @cache.with_local_cache do + assert @cache.write(key, false) + assert_equal false, @cache.read(key) + end + end + + def test_local_cache_should_deserialize_entries_on_multi_get + keys = Array.new(5) { SecureRandom.uuid } + values = keys.index_with(true) + @cache.with_local_cache do + assert @cache.write_multi(values) + assert_equal values, @cache.read_multi(*keys) + end + end +end diff --git a/test/unit/solid_cache_test.rb b/test/unit/solid_cache_test.rb index 5129de0..2376345 100644 --- a/test/unit/solid_cache_test.rb +++ b/test/unit/solid_cache_test.rb @@ -83,15 +83,9 @@ class SolidCacheFailsafeTest < ActiveSupport::TestCase def emulating_unavailability wait_for_background_tasks(@cache) - stub_matcher = ActiveRecord::Base.connection.class.any_instance - stub_matcher.stubs(:exec_query).raises(ActiveRecord::StatementTimeout) - stub_matcher.stubs(:internal_exec_query).raises(ActiveRecord::StatementTimeout) - stub_matcher.stubs(:exec_delete).raises(ActiveRecord::StatementTimeout) - yield lookup_store(namespace: @namespace) - ensure - stub_matcher.unstub(:exec_query) - stub_matcher.unstub(:internal_exec_query) - stub_matcher.unstub(:exec_delete) + emulating_timeouts do + yield lookup_store(namespace: @namespace) + end end end @@ -111,15 +105,9 @@ class SolidCacheRaisingTest < ActiveSupport::TestCase def emulating_unavailability wait_for_background_tasks(@cache) - stub_matcher = ActiveRecord::Base.connection.class.any_instance - stub_matcher.stubs(:exec_query).raises(ActiveRecord::StatementTimeout) - stub_matcher.stubs(:internal_exec_query).raises(ActiveRecord::StatementTimeout) - stub_matcher.stubs(:exec_delete).raises(ActiveRecord::StatementTimeout) - yield lookup_store(namespace: @namespace, - error_handler: ->(method:, returning:, exception:) { raise exception }) - ensure - stub_matcher.unstub(:exec_query) - stub_matcher.unstub(:internal_exec_query) - stub_matcher.unstub(:exec_delete) + emulating_timeouts do + yield lookup_store(namespace: @namespace, + error_handler: ->(method:, returning:, exception:) { raise exception }) + end end end