Skip to content

Commit c82a767

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 c82a767

5 files changed

Lines changed: 307 additions & 4 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, 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.
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: 16 additions & 4 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,19 +28,31 @@ def create_cacheable_methods(original_method_name, opts = {})
2828
end
2929

3030
define_method(method_names[:clear_cache_method_name]) do |*args, **kwargs|
31+
cache_key = __send__(method_names[:key_format_method_name], *args, **kwargs)
32+
@_cacheable_memoized&.dig(original_method_name)&.delete(cache_key) if opts[:memoize]
3133
adapter = (is_a?(Module) ? singleton_class : self.class).cache_adapter
32-
adapter.delete(__send__(method_names[:key_format_method_name], *args, **kwargs))
34+
adapter.delete(cache_key)
3335
end
3436

3537
define_method(method_names[:without_cache_method_name]) do |*args, **kwargs, &block|
3638
method(original_method_name).super_method.call(*args, **kwargs, &block)
3739
end
3840

3941
define_method(method_names[:with_cache_method_name]) do |*args, **kwargs, &block|
42+
cache_key = __send__(method_names[:key_format_method_name], *args, **kwargs)
43+
44+
if opts[:memoize]
45+
method_memo = ((@_cacheable_memoized ||= {})[original_method_name] ||= {})
46+
cached = method_memo.fetch(cache_key, Cacheable::MEMOIZE_NOT_SET)
47+
return cached unless cached.equal?(Cacheable::MEMOIZE_NOT_SET)
48+
end
49+
4050
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
51+
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
4252
__send__(method_names[:without_cache_method_name], *args, **kwargs, &block)
4353
end
54+
method_memo[cache_key] = result if opts[:memoize]
55+
result
4456
end
4557

4658
define_method(original_method_name) do |*args, **kwargs, &block|
@@ -52,7 +64,7 @@ def create_cacheable_methods(original_method_name, opts = {})
5264
end
5365
end
5466
end
55-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
67+
# rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
5668

5769
def default_key_format
5870
warned = false

spec/cacheable/cacheable_spec.rb

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,4 +598,192 @@ 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 'clears only the targeted key when clear_cache is called with args' do
674+
args_method = :args_clear_memoize_method
675+
inner_method = cacheable_method_inner
676+
cacheable_class.class_eval do
677+
define_method(args_method) do |arg|
678+
send inner_method, arg
679+
end
680+
681+
cacheable args_method, memoize: true, key_format: proc { |target, method_name, method_args|
682+
[target.class, method_name, method_args]
683+
}
684+
end
685+
686+
adapter = described_class.cache_adapter
687+
expect(adapter).to receive(:fetch).exactly(3).times.and_call_original
688+
689+
cacheable_object.send(args_method, 'arg1') # fetch 1
690+
cacheable_object.send(args_method, 'arg2') # fetch 2
691+
cacheable_object.send(args_method, 'arg1') # memoized, no fetch
692+
693+
cacheable_object.send("clear_#{args_method}_cache", 'arg1')
694+
695+
cacheable_object.send(args_method, 'arg1') # fetch 3 (cleared)
696+
cacheable_object.send(args_method, 'arg2') # still memoized, no fetch
697+
end
698+
699+
it 'does not memoize when unless proc is true' do
700+
skip_method = :skip_memoize_method
701+
inner_method = cacheable_method_inner
702+
cacheable_class.class_eval do
703+
define_method(skip_method) do
704+
send inner_method
705+
end
706+
707+
cacheable skip_method, memoize: true, unless: proc { true }
708+
end
709+
710+
expect(cacheable_object).to receive(inner_method).twice.and_call_original
711+
2.times { cacheable_object.send(skip_method) }
712+
end
713+
714+
it 'memoizes nil return values' do
715+
nil_method = :nil_memoize_method
716+
call_count = 0
717+
cacheable_class.class_eval do
718+
define_method(nil_method) do
719+
call_count += 1
720+
nil
721+
end
722+
723+
cacheable nil_method, memoize: true
724+
end
725+
726+
2.times { cacheable_object.send(nil_method) }
727+
expect(call_count).to eq(1)
728+
end
729+
730+
it 'memoizes false return values' do
731+
false_method = :false_memoize_method
732+
call_count = 0
733+
cacheable_class.class_eval do
734+
define_method(false_method) do
735+
call_count += 1
736+
false
737+
end
738+
739+
cacheable false_method, memoize: true
740+
end
741+
742+
2.times { cacheable_object.send(false_method) }
743+
expect(call_count).to eq(1)
744+
end
745+
746+
it 'does not set @_cacheable_memoized when memoize is not used' do
747+
non_memo_class = Class.new.tap do |klass|
748+
klass.class_exec do
749+
include Cacheable
750+
751+
def some_method
752+
'value'
753+
end
754+
755+
cacheable :some_method
756+
end
757+
end
758+
759+
obj = non_memo_class.new
760+
obj.some_method
761+
expect(obj.instance_variable_defined?(:@_cacheable_memoized)).to be false
762+
end
763+
764+
it 'passes cache_options to the adapter on the first call' do
765+
opts_method = :opts_memoize_method
766+
cache_options = {expires_in: 3_600}
767+
cacheable_class.class_eval do
768+
define_method(opts_method) { 'value' }
769+
cacheable opts_method, memoize: true, cache_options: cache_options
770+
end
771+
772+
expect(described_class.cache_adapter).to receive(:fetch).with(anything, hash_including(cache_options)).once.and_call_original
773+
2.times { cacheable_object.send(opts_method) }
774+
end
775+
776+
context 'with class methods' do
777+
let(:cacheable_class) do
778+
Class.new.tap { |klass| klass.singleton_class.class_exec(&class_definition) }
779+
end
780+
781+
it 'memoizes class method calls' do
782+
adapter = described_class.cache_adapter
783+
expect(adapter).to receive(:fetch).once.and_call_original
784+
785+
2.times { cacheable_class.send(cacheable_method) }
786+
end
787+
end
788+
end
601789
end

0 commit comments

Comments
 (0)