diff --git a/lib/any_cache.rb b/lib/any_cache.rb index 2a200d2..a72e1e4 100644 --- a/lib/any_cache.rb +++ b/lib/any_cache.rb @@ -75,7 +75,8 @@ def build(driver = Drivers.build(config)) :expire, :persist, :clear, - :exist? + :exist?, + :fetch # @return [AnyCache::Adapters::Basic] # diff --git a/lib/any_cache/adapters/active_support_mem_cache_store.rb b/lib/any_cache/adapters/active_support_mem_cache_store.rb index 20fdec1..fa35a4e 100644 --- a/lib/any_cache/adapters/active_support_mem_cache_store.rb +++ b/lib/any_cache/adapters/active_support_mem_cache_store.rb @@ -143,5 +143,19 @@ def persist(key, **options) def exist?(key, **options) driver.exist?(key) end + + # @param key [String] + # @option expires_in [Integer] + # @return [Object] + # + # @api private + # @since 0.2.0 + def fetch(key, **options, &block) + force_rewrite = options.fetch(:force, false) + force_rewrite = force_rewrite.call if force_rewrite.respond_to?(:call) + expires_in = options.fetch(:expires_in, NO_EXPIRATION_TTL) + + driver.fetch(key, force: force_rewrite, expires_in: expires_in, &block) + end end end diff --git a/lib/any_cache/adapters/active_support_naive_store.rb b/lib/any_cache/adapters/active_support_naive_store.rb index ac9acd4..fe62835 100644 --- a/lib/any_cache/adapters/active_support_naive_store.rb +++ b/lib/any_cache/adapters/active_support_naive_store.rb @@ -128,6 +128,22 @@ def exist?(key, **options) lock.with_read_lock { super } end + # @param key [String] + # @option expires_in [Integer] + # @return [Object] + # + # @api private + # @since 0.2.0 + def fetch(key, **options, &block) + lock.with_write_lock do + force_rewrite = options.fetch(:force, false) + force_rewrite = force_rewrite.call if force_rewrite.respond_to?(:call) + expires_in = options.fetch(:expires_in, self.class::Operation::NO_EXPIRATION_TTL) + + super(key, force: force_rewrite, expires_in: expires_in, &block) + end + end + private # @return [Concurrent::ReentrantReadWriteLock] diff --git a/lib/any_cache/adapters/active_support_redis_cache_store.rb b/lib/any_cache/adapters/active_support_redis_cache_store.rb index 512ca87..a4e5268 100644 --- a/lib/any_cache/adapters/active_support_redis_cache_store.rb +++ b/lib/any_cache/adapters/active_support_redis_cache_store.rb @@ -70,7 +70,7 @@ def write(key, value, **options) # @since 0.1.0 def increment(key, amount = DEFAULT_INCR_DECR_AMOUNT, **options) expires_in = options.fetch(:expires_in, NO_EXPIRATION_TTL) - is_initial = !read(key) # TODO: rewrite with #exist?(key) + is_initial = exist?(key) if is_initial write(key, amount, expires_in: expires_in) && amount @@ -90,7 +90,7 @@ def increment(key, amount = DEFAULT_INCR_DECR_AMOUNT, **options) # @since 0.1.0 def decrement(key, amount = DEFAULT_INCR_DECR_AMOUNT, **options) expires_in = options.fetch(:expires_in, NO_EXPIRATION_TTL) - is_initial = !read(key) # TODO: rewrite with #exist?(key) + is_initial = exist?(key) if is_initial write(key, -amount, expires_in: expires_in) && -amount @@ -133,5 +133,19 @@ def persist(key, **options) def exist?(key, **options) driver.exist?(key) end + + # @param key [String] + # @option expires_in [Integer] + # @return [Object] + # + # @api private + # @since 0.2.0 + def fetch(key, **options, &block) + force_rewrite = options.fetch(:force, false) + force_rewrite = force_rewrite.call if force_rewrite.respond_to?(:call) + expires_in = options.fetch(:expires_in, NO_EXPIRATION_TTL) + + driver.fetch(key, force: force_rewrite, expires_in: expires_in, &block) + end end end diff --git a/lib/any_cache/adapters/basic.rb b/lib/any_cache/adapters/basic.rb index 93c0a02..57c8bf9 100644 --- a/lib/any_cache/adapters/basic.rb +++ b/lib/any_cache/adapters/basic.rb @@ -124,5 +124,15 @@ def clear(**options) def exist?(key, **options) raise NotImplementedError end + + # @param key [String] + # @param options [Hash] + # @return [Object] + # + # @api private + # @since 0.2.0 + def fetch(key, **options, &block) + raise NotImplementedError + end end end diff --git a/lib/any_cache/adapters/dalli.rb b/lib/any_cache/adapters/dalli.rb index 9be2e51..bcc27b7 100644 --- a/lib/any_cache/adapters/dalli.rb +++ b/lib/any_cache/adapters/dalli.rb @@ -144,7 +144,24 @@ def clear(**options) # @api private # @since 0.2.0 def exist?(key, **options) - !get(key).nil? # NOTE: can conflict with :cache_nils Dalli::Client option + !get(key).nil? # NOTE: can conflict with :cache_nils Dalli::Client's config + end + + # @param key [String] + # @option expires_in [Integer] + # @option force [Boolean] + # @return [Object] + # + # @api private + # @since 0.2.0 + def fetch(key, **options) + force_rewrite = options.fetch(:force, false) + force_rewrite = force_rewrite.call if force_rewrite.respond_to?(:call) + + # NOTE: can conflict with :cache_nils Dalli::Client's config + read(key).tap { |value| return value if value } unless force_rewrite + + yield.tap { |value| write(key, value, **options) } if block_given? end end end diff --git a/lib/any_cache/adapters/delegator.rb b/lib/any_cache/adapters/delegator.rb index 0e5695f..4d44f1c 100644 --- a/lib/any_cache/adapters/delegator.rb +++ b/lib/any_cache/adapters/delegator.rb @@ -19,7 +19,8 @@ def supported_driver?(driver) driver.respond_to?(:expire) && driver.respond_to?(:persist) && driver.respond_to?(:clear) && - driver.respond_to?(:exist?) + driver.respond_to?(:exist?) && + driver.respond_to?(:fetch) end end @@ -33,6 +34,7 @@ def supported_driver?(driver) :expire, :persist, :clear, - :exist? + :exist?, + :fetch end end diff --git a/lib/any_cache/adapters/redis.rb b/lib/any_cache/adapters/redis.rb index 425b068..732e84a 100644 --- a/lib/any_cache/adapters/redis.rb +++ b/lib/any_cache/adapters/redis.rb @@ -89,7 +89,6 @@ def increment(key, amount = DEFAULT_INCR_DECR_AMOUNT, **options) expires_in = options.fetch(:expires_in, NO_EXPIRATION_TTL) new_amount = nil - # TODO: think about Redis#multi pipelined do new_amount = incrby(key, amount) expire(key, expires_in: expires_in) if expires_in @@ -109,7 +108,6 @@ def decrement(key, amount = DEFAULT_INCR_DECR_AMOUNT, **options) expires_in = options.fetch(:expires_in, NO_EXPIRATION_TTL) new_amount = nil - # TODO: think about Redis#multi pipelined do new_amount = decrby(key, amount) expire(key, expires_in: expires_in) if expires_in @@ -158,5 +156,21 @@ def clear(**options) def exist?(key, **options) exists(key) end + + # @param key [String] + # @option expires_in [Integer] + # @return [Object] + # + # @api private + # @since 0.2.0 + def fetch(key, **options) + force_rewrite = options.fetch(:force, false) + force_rewrite = force_rewrite.call if force_rewrite.respond_to?(:call) + + # NOTE: think about #pipelined + read(key).tap { |value| return value if value } unless force_rewrite + + yield.tap { |value| write(key, value, **options) } if block_given? + end end end diff --git a/spec/features/clear_spec.rb b/spec/features/clear_spec.rb index 99e91fb..be5f740 100644 --- a/spec/features/clear_spec.rb +++ b/spec/features/clear_spec.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true describe 'Operation: #clear' do - let(:cache_store) { build_cache_store } - - after { cache_store.clear } + include_context 'cache store' it 'clears storage' do # NOTE: write random values diff --git a/spec/features/custom_cache_clients_spec.rb b/spec/features/custom_cache_clients_spec.rb index b230484..d3f9d8e 100644 --- a/spec/features/custom_cache_clients_spec.rb +++ b/spec/features/custom_cache_clients_spec.rb @@ -14,6 +14,7 @@ def expire(key, **); end def persist(key, **); end def clear(key, **); end def exist?(key, **); end + def fetch(key, **); end # rubocop:enable Layout/EmptyLineBetweenDefs end.new end @@ -30,6 +31,7 @@ def expire; end def persist; end def clear; end def exist?; end + def fetch; end end.new # rubocop:enable Layout/EmptyLineBetweenDefs end @@ -48,6 +50,7 @@ def exist?; end expire persist clear + fetch exist? ].each do |operation| specify "AnyCache instance delegates :#{operation} operation to the custom client" do @@ -90,6 +93,7 @@ def exist?; end expire persist clear + fetch exist? ] end diff --git a/spec/features/decrement_spec.rb b/spec/features/decrement_spec.rb index 93f9356..d6be1c5 100644 --- a/spec/features/decrement_spec.rb +++ b/spec/features/decrement_spec.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true describe 'Operation: #decrement' do - after { cache_store.clear } + include_context 'cache store' - let(:cache_store) { build_cache_store } let(:expiration_time) { 8 } # NOTE: in seconds let(:entry) { { key: SecureRandom.hex, value: 1 } } diff --git a/spec/features/delete_spec.rb b/spec/features/delete_spec.rb index 3a86e0b..6d3b607 100644 --- a/spec/features/delete_spec.rb +++ b/spec/features/delete_spec.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true describe 'Operation: #delete' do - after { cache_store.clear } - - let(:cache_store) { build_cache_store } + include_context 'cache store' it 'removes entry from cache' do first_pair = { key: SecureRandom.hex, value: SecureRandom.hex(4) } diff --git a/spec/features/fetch_spec.rb b/spec/features/fetch_spec.rb new file mode 100644 index 0000000..c8eaba1 --- /dev/null +++ b/spec/features/fetch_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +describe 'Command: #fetch' do + include_context 'cache store' + + let(:entry) { { key: SecureRandom.hex, value: SecureRandom.hex(4) } } + let(:expires_in) { 5 } # NOTE: in seconds + + specify 'fetching without expiration attribue creates a permanent entry' do + expect(cache_store.fetch(entry[:key])).to eq(nil) + + result = cache_store.fetch(entry[:key]) { entry[:value] } + + expect(result).to eq(entry[:value]) + expect(cache_store.fetch(entry[:key])).to eq(entry[:value]) + + sleep(expires_in + 1) + + expect(cache_store.fetch(entry[:key])).to eq(entry[:value]) + end + + specify 'fetching with expiration attribute creates a temporal entry' do + expect(cache_store.fetch(entry[:key])).to eq(nil) + + result = cache_store.fetch(entry[:key], expires_in: expires_in) { entry[:value] } + + expect(result).to eq(entry[:value]) + expect(cache_store.fetch(entry[:key])).to eq(entry[:value]) + + sleep(expires_in + 1) + + expect(cache_store.fetch(entry[:key])).to eq(nil) + end + + specify 'fetching with :force option creates new entry' do + expect(cache_store.fetch(entry[:key])).to eq(nil) + + # NOTE: initial permanent entry + entry_value = SecureRandom.hex(4) + result = cache_store.fetch(entry[:key], force: true) { entry_value } + + expect(result).to eq(entry_value) + expect(cache_store.fetch(entry[:key])).to eq(entry_value) + + # NOTE: new permanent value + entry_value = SecureRandom.hex(4) + result = cache_store.fetch(entry[:key], force: true) { entry_value } + + expect(result).to eq(entry_value) + expect(cache_store.fetch(entry[:key])).to eq(entry_value) + + # NOTE: new temporal entry + entry_value = SecureRandom.hex(4) + result = cache_store.fetch(entry[:key], expires_in: expires_in, force: true) do + entry_value + end + + expect(result).to eq(entry_value) + expect(cache_store.fetch(entry[:key])).to eq(entry_value) + sleep(expires_in + 1) # NOTE: expire new temporal entry + expect(cache_store.fetch(entry[:key])).to eq(nil) + + # NOTE: prepare new temporal entry + entry_value = SecureRandom.hex(4) + cache_store.write(entry[:key], entry[:value], expires_in: expires_in) + + # NOTE: rewrite new temporal entry with permanent entry + result = cache_store.fetch(entry[:key], force: true) { entry_value } + expect(result).to eq(entry_value) + expect(cache_store.fetch(entry[:key])).to eq(entry_value) + + sleep(expires_in + 1) # NOTE: try to expire rewritten entry + + expect(cache_store.fetch(entry[:key])).to eq(entry_value) + end +end diff --git a/spec/features/increment_spec.rb b/spec/features/increment_spec.rb index 1276fb3..cda6955 100644 --- a/spec/features/increment_spec.rb +++ b/spec/features/increment_spec.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true describe 'Operation: #increment' do - after { cache_store.clear } + include_context 'cache store' - let(:cache_store) { build_cache_store } let(:expiration_time) { 8 } # NOTE: in seconds let(:entry) { { key: SecureRandom.hex, value: 1 } } diff --git a/spec/features/persist_spec.rb b/spec/features/persist_spec.rb index ab53aac..1db6e64 100644 --- a/spec/features/persist_spec.rb +++ b/spec/features/persist_spec.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true describe 'Operation: #persist' do - after { cache_store.clear } + include_context 'cache store' - let(:cache_store) { build_cache_store } let(:first_entry) { { key: SecureRandom.hex, value: SecureRandom.hex(4) } } let(:second_entry) { { key: SecureRandom.hex, value: SecureRandom.hex(4) } } diff --git a/spec/features/read_spec.rb b/spec/features/read_spec.rb index 0bfaaf1..4f26b48 100644 --- a/spec/features/read_spec.rb +++ b/spec/features/read_spec.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true describe 'Operation: #read' do - after { cache_store.clear } - - let(:cache_store) { build_cache_store } + include_context 'cache store' context 'when the required entry exists' do let(:expiration_time) { 8 } # NOTE: in seconds diff --git a/spec/features/write_spec.rb b/spec/features/write_spec.rb index 3dc7bee..60df90f 100644 --- a/spec/features/write_spec.rb +++ b/spec/features/write_spec.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true describe 'Operation: #write' do - after { cache_store.clear } + include_context 'cache store' - let(:cache_store) { build_cache_store } let(:expiration_time) { 8 } # NOTE: in seconds let(:first_pair) { { key: SecureRandom.hex, value: SecureRandom.hex(4) } } let(:second_pair) { { key: SecureRandom.hex, value: SecureRandom.hex(4) } } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 77acd06..af75f57 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -15,6 +15,7 @@ require 'pry' require_relative 'support/spec_support' +require_relative 'support/shared_contexts' RSpec.configure do |config| config.filter_run_when_matching :focus diff --git a/spec/support/shared_contexts.rb b/spec/support/shared_contexts.rb new file mode 100644 index 0000000..9b0de59 --- /dev/null +++ b/spec/support/shared_contexts.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'shared_contexts/cache_store' diff --git a/spec/support/shared_contexts/cache_store.rb b/spec/support/shared_contexts/cache_store.rb new file mode 100644 index 0000000..f87837f --- /dev/null +++ b/spec/support/shared_contexts/cache_store.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +shared_context 'cache store' do + after { cache_store.clear } + + let(:cache_store) { build_cache_store } +end