Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions examples/memoize_example.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions lib/cacheable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 16 additions & 4 deletions lib/cacheable/method_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,19 +28,31 @@ 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|
method(original_method_name).super_method.call(*args, **kwargs, &block)
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|
Expand All @@ -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
Expand Down
188 changes: 188 additions & 0 deletions spec/cacheable/cacheable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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