Skip to content

Commit ae64cf7

Browse files
authored
Merge pull request #22 from splitwise/ar/memoize-option
Add memoize: true option for per-instance caching
2 parents 40aed1e + c82a767 commit ae64cf7

File tree

5 files changed

+307
-4
lines changed

5 files changed

+307
-4
lines changed

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)