From c82a767faabcb0577353c31a3c6901c546e1bdc7 Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Fri, 27 Feb 2026 12:00:22 -0500 Subject: [PATCH] Add memoize option for per-instance caching of deserialized values When `memoize: true` is passed to `cacheable`, repeated calls on the same instance skip the cache adapter entirely and return the previously deserialized result. This avoids expensive repeated deserialization for objects like ActiveRecord models. Memoized values are cleared by `clear_*_cache` and garbage collected with the instance. Closes #15 Co-Authored-By: Claude Opus 4.6 --- README.md | 46 ++++++++ examples/memoize_example.rb | 54 +++++++++ lib/cacheable.rb | 3 + lib/cacheable/method_generator.rb | 20 +++- spec/cacheable/cacheable_spec.rb | 188 ++++++++++++++++++++++++++++++ 5 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 examples/memoize_example.rb 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