diff --git a/README.md b/README.md index f845d44..0fd6238 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,52 @@ If your cache backend supports options, you can pass them as the `cache_options: cacheable :with_options, cache_options: {expires_in: 3_600} ``` +### Memoization + +By default, every call to a cached method hits the cache adapter, which includes deserialization. For methods where the deserialized object is expensive to reconstruct (e.g., large ActiveRecord collections), you can enable per-instance memoization so that repeated calls on the **same object** skip the adapter entirely: + +```ruby +# From examples/memoize_example.rb + +class ExpensiveService + include Cacheable + + cacheable :without_memoize + + cacheable :with_memoize, memoize: true + + def without_memoize + puts ' [method] computing value' + 42 + end + + def with_memoize + puts ' [method] computing value' + 42 + end +end +``` + +Using a logging adapter wrapper (see `examples/memoize_example.rb` for the full setup), the difference becomes clear: + +``` +--- without memoize --- + [cache] fetch ["ExpensiveService", :without_memoize] + [method] computing value + [cache] fetch ["ExpensiveService", :without_memoize] <-- adapter hit again (deserialization cost) + +--- with memoize: true --- + [cache] fetch ["ExpensiveService", :with_memoize] + [method] computing value + <-- no adapter hit on second call + +--- after clearing --- + [cache] fetch ["ExpensiveService", :with_memoize] <-- adapter hit again after clear + [method] computing value +``` + +**Important**: Memoized values persist for the lifetime of the object instance, and after the first call they bypass the cache adapter entirely. This means adapter-driven expiration (`expires_in`) and other backend invalidation mechanisms will **not** be re-checked while the instance stays alive. If your cache key changes (e.g., `cache_key` based on `updated_at`), the memoized value will also **not** automatically update. This is especially important for class-method memoization (where the "instance" is the class itself), because the memo can effectively outlive the cache TTL. Use `memoize: true` only when you know the value will not change for the lifetime of the instance (or class), or call `clear_#{method}_cache` explicitly when needed. + ### Per-Class Cache Adapter By default, all classes use the global adapter set via `Cacheable.cache_adapter`. If you need a specific class to use a different cache backend, you can set one directly on the class: diff --git a/examples/memoize_example.rb b/examples/memoize_example.rb new file mode 100644 index 0000000..34d1e1a --- /dev/null +++ b/examples/memoize_example.rb @@ -0,0 +1,54 @@ +require 'cacheable' # this may not be necessary depending on your autoloading system + +# Wrap the default adapter to log cache reads +logging_adapter = Cacheable::CacheAdapters::MemoryAdapter.new +original_fetch = logging_adapter.method(:fetch) +logging_adapter.define_singleton_method(:fetch) do |key, *args, &block| + puts " [cache] fetch #{key.inspect}" + original_fetch.call(key, *args, &block) +end + +Cacheable.cache_adapter = logging_adapter + +class ExpensiveService + include Cacheable + + cacheable :without_memoize + + cacheable :with_memoize, memoize: true + + def without_memoize + puts ' [method] computing value' + 42 + end + + def with_memoize + puts ' [method] computing value' + 42 + end +end + +svc = ExpensiveService.new + +puts '--- without memoize ---' +2.times { svc.without_memoize } +# --- without memoize --- +# [cache] fetch ["ExpensiveService", :without_memoize] +# [method] computing value +# [cache] fetch ["ExpensiveService", :without_memoize] <-- adapter hit again (deserialization cost) + +puts +puts '--- with memoize: true ---' +2.times { svc.with_memoize } +# --- with memoize: true --- +# [cache] fetch ["ExpensiveService", :with_memoize] +# [method] computing value +# <-- no adapter hit, returned from instance memo + +puts +puts '--- after clearing ---' +svc.clear_with_memoize_cache +svc.with_memoize +# --- after clearing --- +# [cache] fetch ["ExpensiveService", :with_memoize] +# [method] computing value diff --git a/lib/cacheable.rb b/lib/cacheable.rb index 450b1ea..819b1be 100644 --- a/lib/cacheable.rb +++ b/lib/cacheable.rb @@ -31,6 +31,9 @@ module Cacheable extend CacheAdapter + # Sentinel value to distinguish "not yet memoized" from a memoized nil/false. + MEMOIZE_NOT_SET = Object.new.freeze + def self.included(base) base.extend(Cacheable::CacheAdapter) base.extend(Cacheable::MethodGenerator) diff --git a/lib/cacheable/method_generator.rb b/lib/cacheable/method_generator.rb index 23c200d..7ba3e1a 100644 --- a/lib/cacheable/method_generator.rb +++ b/lib/cacheable/method_generator.rb @@ -15,7 +15,7 @@ def method_interceptor_module_name "#{class_name}Cacher" end - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def create_cacheable_methods(original_method_name, opts = {}) method_names = create_method_names(original_method_name) key_format_proc = opts[:key_format] || default_key_format @@ -28,8 +28,10 @@ def create_cacheable_methods(original_method_name, opts = {}) end define_method(method_names[:clear_cache_method_name]) do |*args, **kwargs| + cache_key = __send__(method_names[:key_format_method_name], *args, **kwargs) + @_cacheable_memoized&.dig(original_method_name)&.delete(cache_key) if opts[:memoize] adapter = (is_a?(Module) ? singleton_class : self.class).cache_adapter - adapter.delete(__send__(method_names[:key_format_method_name], *args, **kwargs)) + adapter.delete(cache_key) end define_method(method_names[:without_cache_method_name]) do |*args, **kwargs, &block| @@ -37,10 +39,20 @@ def create_cacheable_methods(original_method_name, opts = {}) end define_method(method_names[:with_cache_method_name]) do |*args, **kwargs, &block| + cache_key = __send__(method_names[:key_format_method_name], *args, **kwargs) + + if opts[:memoize] + method_memo = ((@_cacheable_memoized ||= {})[original_method_name] ||= {}) + cached = method_memo.fetch(cache_key, Cacheable::MEMOIZE_NOT_SET) + return cached unless cached.equal?(Cacheable::MEMOIZE_NOT_SET) + end + adapter = (is_a?(Module) ? singleton_class : self.class).cache_adapter - adapter.fetch(__send__(method_names[:key_format_method_name], *args, **kwargs), opts[:cache_options]) do # rubocop:disable Lint/UselessDefaultValueArgument -- not Hash#fetch; second arg is cache options (e.g. expires_in) passed to the adapter + result = adapter.fetch(cache_key, opts[:cache_options]) do # rubocop:disable Lint/UselessDefaultValueArgument -- not Hash#fetch; second arg is cache options (e.g. expires_in) passed to the adapter __send__(method_names[:without_cache_method_name], *args, **kwargs, &block) end + method_memo[cache_key] = result if opts[:memoize] + result end define_method(original_method_name) do |*args, **kwargs, &block| @@ -52,7 +64,7 @@ def create_cacheable_methods(original_method_name, opts = {}) end end end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def default_key_format warned = false diff --git a/spec/cacheable/cacheable_spec.rb b/spec/cacheable/cacheable_spec.rb index dcec584..4e376f3 100644 --- a/spec/cacheable/cacheable_spec.rb +++ b/spec/cacheable/cacheable_spec.rb @@ -598,4 +598,192 @@ def cache_control_method expect(described_class.cache_adapter).to receive(:fetch).with(anything, hash_including(cache_options)) cacheable_object.send(cache_method_with_cache_options) end + + describe 'memoization' do + let(:class_definition) do + cacheable_method_name = cacheable_method + cacheable_method_inner_name = cacheable_method_inner + mod = described_class + proc do + include mod + + define_method(cacheable_method_name) do |arg = nil| + send cacheable_method_inner_name, arg + end + + define_method(cacheable_method_inner_name) do |arg = nil| + "a unique value with arg #{arg}" + end + + cacheable cacheable_method_name, memoize: true + end + end + + it 'returns the expected value' do + expect(cacheable_object.send(cacheable_method)).to eq(cacheable_object.send(cacheable_method_inner)) + end + + it 'only hits the cache adapter once for repeated calls' do + adapter = described_class.cache_adapter + expect(adapter).to receive(:fetch).once.and_call_original + + 2.times { cacheable_object.send(cacheable_method) } + end + + it 'different instances have independent memoization' do + obj1 = cacheable_class.new + obj2 = cacheable_class.new + + obj1.send(cacheable_method) + obj2.send(cacheable_method) + + # Each should have its own memo store + expect(obj1.instance_variable_get(:@_cacheable_memoized)).not_to be(obj2.instance_variable_get(:@_cacheable_memoized)) + end + + it 'memoizes different arguments independently when key_format includes args' do + args_method = :args_memoize_method + inner_method = cacheable_method_inner + cacheable_class.class_eval do + define_method(args_method) do |arg| + send inner_method, arg + end + + cacheable args_method, memoize: true, key_format: proc { |target, method_name, method_args| + [target.class, method_name, method_args] + } + end + + adapter = described_class.cache_adapter + expect(adapter).to receive(:fetch).twice.and_call_original + + 2.times { cacheable_object.send(args_method, 'arg1') } + 2.times { cacheable_object.send(args_method, 'arg2') } + end + + it 'clears memoized value when clear_cache is called' do + adapter = described_class.cache_adapter + expect(adapter).to receive(:fetch).twice.and_call_original + + cacheable_object.send(cacheable_method) + cacheable_object.send("clear_#{cacheable_method}_cache") + cacheable_object.send(cacheable_method) + end + + it 'clears only the targeted key when clear_cache is called with args' do + args_method = :args_clear_memoize_method + inner_method = cacheable_method_inner + cacheable_class.class_eval do + define_method(args_method) do |arg| + send inner_method, arg + end + + cacheable args_method, memoize: true, key_format: proc { |target, method_name, method_args| + [target.class, method_name, method_args] + } + end + + adapter = described_class.cache_adapter + expect(adapter).to receive(:fetch).exactly(3).times.and_call_original + + cacheable_object.send(args_method, 'arg1') # fetch 1 + cacheable_object.send(args_method, 'arg2') # fetch 2 + cacheable_object.send(args_method, 'arg1') # memoized, no fetch + + cacheable_object.send("clear_#{args_method}_cache", 'arg1') + + cacheable_object.send(args_method, 'arg1') # fetch 3 (cleared) + cacheable_object.send(args_method, 'arg2') # still memoized, no fetch + end + + it 'does not memoize when unless proc is true' do + skip_method = :skip_memoize_method + inner_method = cacheable_method_inner + cacheable_class.class_eval do + define_method(skip_method) do + send inner_method + end + + cacheable skip_method, memoize: true, unless: proc { true } + end + + expect(cacheable_object).to receive(inner_method).twice.and_call_original + 2.times { cacheable_object.send(skip_method) } + end + + it 'memoizes nil return values' do + nil_method = :nil_memoize_method + call_count = 0 + cacheable_class.class_eval do + define_method(nil_method) do + call_count += 1 + nil + end + + cacheable nil_method, memoize: true + end + + 2.times { cacheable_object.send(nil_method) } + expect(call_count).to eq(1) + end + + it 'memoizes false return values' do + false_method = :false_memoize_method + call_count = 0 + cacheable_class.class_eval do + define_method(false_method) do + call_count += 1 + false + end + + cacheable false_method, memoize: true + end + + 2.times { cacheable_object.send(false_method) } + expect(call_count).to eq(1) + end + + it 'does not set @_cacheable_memoized when memoize is not used' do + non_memo_class = Class.new.tap do |klass| + klass.class_exec do + include Cacheable + + def some_method + 'value' + end + + cacheable :some_method + end + end + + obj = non_memo_class.new + obj.some_method + expect(obj.instance_variable_defined?(:@_cacheable_memoized)).to be false + end + + it 'passes cache_options to the adapter on the first call' do + opts_method = :opts_memoize_method + cache_options = {expires_in: 3_600} + cacheable_class.class_eval do + define_method(opts_method) { 'value' } + cacheable opts_method, memoize: true, cache_options: cache_options + end + + expect(described_class.cache_adapter).to receive(:fetch).with(anything, hash_including(cache_options)).once.and_call_original + 2.times { cacheable_object.send(opts_method) } + end + + context 'with class methods' do + let(:cacheable_class) do + Class.new.tap { |klass| klass.singleton_class.class_exec(&class_definition) } + end + + it 'memoizes class method calls' do + adapter = described_class.cache_adapter + expect(adapter).to receive(:fetch).once.and_call_original + + 2.times { cacheable_class.send(cacheable_method) } + end + end + end end