Skip to content

Commit 643e670

Browse files
agrbergclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 4eb4a46 commit 643e670

5 files changed

Lines changed: 279 additions & 3 deletions

File tree

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,52 @@ If your cache backend supports options, you can pass them as the `cache_options:
254254
cacheable :with_options, cache_options: {expires_in: 3_600}
255255
```
256256

257+
### Memoization
258+
259+
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:
260+
261+
```ruby
262+
# From examples/memoize_example.rb
263+
264+
class ExpensiveService
265+
include Cacheable
266+
267+
cacheable :without_memoize
268+
269+
cacheable :with_memoize, memoize: true
270+
271+
def without_memoize
272+
puts ' [method] computing value'
273+
42
274+
end
275+
276+
def with_memoize
277+
puts ' [method] computing value'
278+
42
279+
end
280+
end
281+
```
282+
283+
Using a logging adapter wrapper (see `examples/memoize_example.rb` for the full setup), the difference becomes clear:
284+
285+
```
286+
--- without memoize ---
287+
[cache] fetch ["ExpensiveService", :without_memoize]
288+
[method] computing value
289+
[cache] fetch ["ExpensiveService", :without_memoize] <-- adapter hit again (deserialization cost)
290+
291+
--- with memoize: true ---
292+
[cache] fetch ["ExpensiveService", :with_memoize]
293+
[method] computing value
294+
<-- no adapter hit on second call
295+
296+
--- after clearing ---
297+
[cache] fetch ["ExpensiveService", :with_memoize] <-- adapter hit again after clear
298+
[method] computing value
299+
```
300+
301+
**Important**: Memoized values persist for the lifetime of the object instance. If your cache key changes (e.g., `cache_key` based on `updated_at`), the memoized value will **not** automatically update. Use `memoize: true` only when you know the value will not change for the lifetime of the instance, or call `clear_#{method}_cache` explicitly when needed.
302+
257303
### Per-Class Cache Adapter
258304

259305
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:

examples/memoize_example.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
require 'cacheable' # this may not be necessary depending on your autoloading system
2+
3+
# Wrap the default adapter to log cache reads
4+
logging_adapter = Cacheable::CacheAdapters::MemoryAdapter.new
5+
original_fetch = logging_adapter.method(:fetch)
6+
logging_adapter.define_singleton_method(:fetch) do |key, *args, &block|
7+
puts " [cache] fetch #{key.inspect}"
8+
original_fetch.call(key, *args, &block)
9+
end
10+
11+
Cacheable.cache_adapter = logging_adapter
12+
13+
class ExpensiveService
14+
include Cacheable
15+
16+
cacheable :without_memoize
17+
18+
cacheable :with_memoize, memoize: true
19+
20+
def without_memoize
21+
puts ' [method] computing value'
22+
42
23+
end
24+
25+
def with_memoize
26+
puts ' [method] computing value'
27+
42
28+
end
29+
end
30+
31+
svc = ExpensiveService.new
32+
33+
puts '--- without memoize ---'
34+
2.times { svc.without_memoize }
35+
# --- without memoize ---
36+
# [cache] fetch ["ExpensiveService", :without_memoize]
37+
# [method] computing value
38+
# [cache] fetch ["ExpensiveService", :without_memoize] <-- adapter hit again (deserialization cost)
39+
40+
puts
41+
puts '--- with memoize: true ---'
42+
2.times { svc.with_memoize }
43+
# --- with memoize: true ---
44+
# [cache] fetch ["ExpensiveService", :with_memoize]
45+
# [method] computing value
46+
# <-- no adapter hit, returned from instance memo
47+
48+
puts
49+
puts '--- after clearing ---'
50+
svc.clear_with_memoize_cache
51+
svc.with_memoize
52+
# --- after clearing ---
53+
# [cache] fetch ["ExpensiveService", :with_memoize]
54+
# [method] computing value

lib/cacheable.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
module Cacheable
3232
extend CacheAdapter
3333

34+
# Sentinel value to distinguish "not yet memoized" from a memoized nil/false.
35+
MEMOIZE_NOT_SET = Object.new.freeze
36+
3437
def self.included(base)
3538
base.extend(Cacheable::CacheAdapter)
3639
base.extend(Cacheable::MethodGenerator)

lib/cacheable/method_generator.rb

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def method_interceptor_module_name
1515
"#{class_name}Cacher"
1616
end
1717

18-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
18+
# rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
1919
def create_cacheable_methods(original_method_name, opts = {})
2020
method_names = create_method_names(original_method_name)
2121
key_format_proc = opts[:key_format] || default_key_format
@@ -28,6 +28,7 @@ def create_cacheable_methods(original_method_name, opts = {})
2828
end
2929

3030
define_method(method_names[:clear_cache_method_name]) do |*args, **kwargs|
31+
@_cacheable_memoized&.delete(original_method_name) if opts[:memoize]
3132
adapter = (is_a?(Module) ? singleton_class : self.class).cache_adapter
3233
adapter.delete(__send__(method_names[:key_format_method_name], *args, **kwargs))
3334
end
@@ -37,10 +38,20 @@ def create_cacheable_methods(original_method_name, opts = {})
3738
end
3839

3940
define_method(method_names[:with_cache_method_name]) do |*args, **kwargs, &block|
41+
cache_key = __send__(method_names[:key_format_method_name], *args, **kwargs)
42+
43+
if opts[:memoize]
44+
method_memo = ((@_cacheable_memoized ||= {})[original_method_name] ||= {})
45+
cached = method_memo.fetch(cache_key, Cacheable::MEMOIZE_NOT_SET)
46+
return cached unless cached.equal?(Cacheable::MEMOIZE_NOT_SET)
47+
end
48+
4049
adapter = (is_a?(Module) ? singleton_class : self.class).cache_adapter
41-
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
50+
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
4251
__send__(method_names[:without_cache_method_name], *args, **kwargs, &block)
4352
end
53+
method_memo[cache_key] = result if opts[:memoize]
54+
result
4455
end
4556

4657
define_method(original_method_name) do |*args, **kwargs, &block|
@@ -52,7 +63,7 @@ def create_cacheable_methods(original_method_name, opts = {})
5263
end
5364
end
5465
end
55-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
66+
# rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
5667

5768
def default_key_format
5869
warned = false

spec/cacheable/cacheable_spec.rb

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,4 +598,166 @@ def cache_control_method
598598
expect(described_class.cache_adapter).to receive(:fetch).with(anything, hash_including(cache_options))
599599
cacheable_object.send(cache_method_with_cache_options)
600600
end
601+
602+
describe 'memoization' do
603+
let(:class_definition) do
604+
cacheable_method_name = cacheable_method
605+
cacheable_method_inner_name = cacheable_method_inner
606+
mod = described_class
607+
proc do
608+
include mod
609+
610+
define_method(cacheable_method_name) do |arg = nil|
611+
send cacheable_method_inner_name, arg
612+
end
613+
614+
define_method(cacheable_method_inner_name) do |arg = nil|
615+
"a unique value with arg #{arg}"
616+
end
617+
618+
cacheable cacheable_method_name, memoize: true
619+
end
620+
end
621+
622+
it 'returns the expected value' do
623+
expect(cacheable_object.send(cacheable_method)).to eq(cacheable_object.send(cacheable_method_inner))
624+
end
625+
626+
it 'only hits the cache adapter once for repeated calls' do
627+
adapter = described_class.cache_adapter
628+
expect(adapter).to receive(:fetch).once.and_call_original
629+
630+
2.times { cacheable_object.send(cacheable_method) }
631+
end
632+
633+
it 'different instances have independent memoization' do
634+
obj1 = cacheable_class.new
635+
obj2 = cacheable_class.new
636+
637+
obj1.send(cacheable_method)
638+
obj2.send(cacheable_method)
639+
640+
# Each should have its own memo store
641+
expect(obj1.instance_variable_get(:@_cacheable_memoized)).not_to be(obj2.instance_variable_get(:@_cacheable_memoized))
642+
end
643+
644+
it 'memoizes different arguments independently when key_format includes args' do
645+
args_method = :args_memoize_method
646+
inner_method = cacheable_method_inner
647+
cacheable_class.class_eval do
648+
define_method(args_method) do |arg|
649+
send inner_method, arg
650+
end
651+
652+
cacheable args_method, memoize: true, key_format: proc { |target, method_name, method_args|
653+
[target.class, method_name, method_args]
654+
}
655+
end
656+
657+
adapter = described_class.cache_adapter
658+
expect(adapter).to receive(:fetch).twice.and_call_original
659+
660+
2.times { cacheable_object.send(args_method, 'arg1') }
661+
2.times { cacheable_object.send(args_method, 'arg2') }
662+
end
663+
664+
it 'clears memoized value when clear_cache is called' do
665+
adapter = described_class.cache_adapter
666+
expect(adapter).to receive(:fetch).twice.and_call_original
667+
668+
cacheable_object.send(cacheable_method)
669+
cacheable_object.send("clear_#{cacheable_method}_cache")
670+
cacheable_object.send(cacheable_method)
671+
end
672+
673+
it 'does not memoize when unless proc is true' do
674+
skip_method = :skip_memoize_method
675+
inner_method = cacheable_method_inner
676+
cacheable_class.class_eval do
677+
define_method(skip_method) do
678+
send inner_method
679+
end
680+
681+
cacheable skip_method, memoize: true, unless: proc { true }
682+
end
683+
684+
expect(cacheable_object).to receive(inner_method).twice.and_call_original
685+
2.times { cacheable_object.send(skip_method) }
686+
end
687+
688+
it 'memoizes nil return values' do
689+
nil_method = :nil_memoize_method
690+
call_count = 0
691+
cacheable_class.class_eval do
692+
define_method(nil_method) do
693+
call_count += 1
694+
nil
695+
end
696+
697+
cacheable nil_method, memoize: true
698+
end
699+
700+
2.times { cacheable_object.send(nil_method) }
701+
expect(call_count).to eq(1)
702+
end
703+
704+
it 'memoizes false return values' do
705+
false_method = :false_memoize_method
706+
call_count = 0
707+
cacheable_class.class_eval do
708+
define_method(false_method) do
709+
call_count += 1
710+
false
711+
end
712+
713+
cacheable false_method, memoize: true
714+
end
715+
716+
2.times { cacheable_object.send(false_method) }
717+
expect(call_count).to eq(1)
718+
end
719+
720+
it 'does not set @_cacheable_memoized when memoize is not used' do
721+
non_memo_class = Class.new.tap do |klass|
722+
klass.class_exec do
723+
include Cacheable
724+
725+
def some_method
726+
'value'
727+
end
728+
729+
cacheable :some_method
730+
end
731+
end
732+
733+
obj = non_memo_class.new
734+
obj.some_method
735+
expect(obj.instance_variable_defined?(:@_cacheable_memoized)).to be false
736+
end
737+
738+
it 'passes cache_options to the adapter on the first call' do
739+
opts_method = :opts_memoize_method
740+
cache_options = {expires_in: 3_600}
741+
cacheable_class.class_eval do
742+
define_method(opts_method) { 'value' }
743+
cacheable opts_method, memoize: true, cache_options: cache_options
744+
end
745+
746+
expect(described_class.cache_adapter).to receive(:fetch).with(anything, hash_including(cache_options)).once.and_call_original
747+
2.times { cacheable_object.send(opts_method) }
748+
end
749+
750+
context 'with class methods' do
751+
let(:cacheable_class) do
752+
Class.new.tap { |klass| klass.singleton_class.class_exec(&class_definition) }
753+
end
754+
755+
it 'memoizes class method calls' do
756+
adapter = described_class.cache_adapter
757+
expect(adapter).to receive(:fetch).once.and_call_original
758+
759+
2.times { cacheable_class.send(cacheable_method) }
760+
end
761+
end
762+
end
601763
end

0 commit comments

Comments
 (0)