From 2f88816a6c25ca7ff54952495325f397fcd310ce Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Thu, 26 Feb 2026 09:37:19 -0500 Subject: [PATCH 01/10] Forward keyword arguments and blocks through cached methods Generated methods only accepted *args, silently dropping keyword arguments and blocks. This caused methods with kwargs to break when wrapped with `cacheable`. Changes: - All generated methods now accept *args, **kwargs, &block - key_format procs receive kwargs as well - unless proc is resolved once at definition time instead of per-call - Use Bundler.setup instead of Bundler.require in spec_helper for Ruby 4.0 compatibility Co-Authored-By: Claude Opus 4.6 --- README.md | 57 +++++++++++----------- examples/conditional_example.rb | 25 +++++----- examples/custom_key_example.rb | 21 ++++---- lib/cacheable/method_generator.rb | 33 ++++++------- spec/cacheable/cacheable_spec.rb | 81 +++++++++++++++++++++++++++++++ spec/spec_helper.rb | 4 +- 6 files changed, 152 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index a44e9f0..ff694e6 100644 --- a/README.md +++ b/README.md @@ -155,12 +155,13 @@ require 'net/http' class GitHubApiAdapter include Cacheable - cacheable :star_count, key_format: ->(target, method_name, method_args) do - [target.class, method_name, method_args.first, Time.now.strftime('%Y-%m-%d')].join('/') + cacheable :star_count, key_format: ->(target, method_name, method_args, **kwargs) do + date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d')) + [target.class, method_name, method_args.first, date].join('/') end - def star_count(repo) - puts "Fetching data from GitHub for #{repo}" + def star_count(repo, date: Time.now.strftime('%Y-%m-%d')) + puts "Fetching data from GitHub for #{repo} (as of #{date})" url = "https://api.github.com/repos/splitwise/#{repo}" JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count'] @@ -170,33 +171,34 @@ end * `target` is the object the method is being called on (`#`) * `method_name` is the name of the method being cached (`:star_count`) -* `method_args` is an array of arguments being passed to the method (`[params]`) +* `method_args` is an array of positional arguments being passed to the method (`[params]`) +* `**kwargs` are the keyword arguments being passed to the method Including the method argument(s) allows you to cache different calls to the same method. Without the arguments in the cache key, a call to `star_count('cacheable')` would populate the cache and `star_count('tokenautocomplete')` would return the number of stars for Cacheable instead of what you want. -In addition, we're including the current date in the cache key so calling this method tomorrow will return an updated value. +**Note:** The `key_format` proc only receives keyword arguments that the caller explicitly passes — method defaults are not included. That's why the proc uses `kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d'))` to compute its own default when `date:` is omitted. This ensures the cache key always varies by date. ```irb > a = GitHubApiAdapter.new > a.star_count('cacheable') -Fetching data from GitHub for cacheable - => 19 +Fetching data from GitHub for cacheable (as of 2026-02-26) + => 58 > a.star_count('cacheable') - => 19 + => 58 > a.star_count('tokenautocomplete') -Fetching data from GitHub for tokenautocomplete - => 1164 +Fetching data from GitHub for tokenautocomplete (as of 2026-02-26) + => 1309 > a.star_count('tokenautocomplete') - => 1164 + => 1309 # In this example the follow cache keys are generated: - # GitHubApiAdapter/star_count/cacheable/2018-09-21 - # GitHubApiAdapter/star_count/tokenautocomplete/2018-09-21 + # GitHubApiAdapter/star_count/cacheable/2026-02-26 + # GitHubApiAdapter/star_count/tokenautocomplete/2026-02-26 ``` ### Conditional Caching -You can control if a method should be cached by supplying a proc to the `unless:` option which will get the same arguments as `key_format:`. This logic can be defined in a method on the class and the name of the method as a symbol can be passed as well. **Note**: When using a symbol, the first argument, `target`, will not be passed but will be available as `self`. +You can control if a method should be cached by supplying a proc to the `unless:` option which will get the same arguments as `key_format:` (`target, method_name, method_args, **kwargs`). This logic can be defined in a method on the class and the name of the method as a symbol can be passed as well. **Note**: When using a symbol, the first argument, `target`, will not be passed but will be available as `self`. ```ruby # From examples/conditional_example.rb @@ -208,18 +210,19 @@ require 'net/http' class GitHubApiAdapter include Cacheable - cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args) do - [target.class, method_name, method_args.first].join('/') + cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args, **kwargs) do + date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d')) + [target.class, method_name, method_args.first, date].join('/') end - def star_count(repo) - puts "Fetching data from GitHub for #{repo}" + def star_count(repo, date: Time.now.strftime('%Y-%m-%d')) + puts "Fetching data from GitHub for #{repo} (as of #{date})" url = "https://api.github.com/repos/splitwise/#{repo}" JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count'] end - def growing_fast?(_method_name, method_args) + def growing_fast?(_method_name, method_args, **) method_args.first == 'cacheable' end end @@ -230,17 +233,17 @@ Cacheable is new so we don't want to cache the number of stars it has as we expe ```irb > a = GitHubApiAdapter.new > a.star_count('tokenautocomplete') -Fetching data from GitHub for tokenautocomplete - => 1164 +Fetching data from GitHub for tokenautocomplete (as of 2026-02-26) + => 1309 a.star_count('tokenautocomplete') - => 1164 + => 1309 > a.star_count('cacheable') -Fetching data from GitHub for cacheable - => 19 +Fetching data from GitHub for cacheable (as of 2026-02-26) + => 58 > a.star_count('cacheable') -Fetching data from GitHub for cacheable - => 19 +Fetching data from GitHub for cacheable (as of 2026-02-26) + => 58 ``` ### Cache Options diff --git a/examples/conditional_example.rb b/examples/conditional_example.rb index f0d7abc..59b98da 100644 --- a/examples/conditional_example.rb +++ b/examples/conditional_example.rb @@ -5,32 +5,33 @@ class GitHubApiAdapter include Cacheable - cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args) do - [target.class, method_name, method_args.first].join('/') + cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args, **kwargs) do + date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d')) + [target.class, method_name, method_args.first, date].join('/') end - def star_count(repo) - puts "Fetching data from GitHub for #{repo}" + def star_count(repo, date: Time.now.strftime('%Y-%m-%d')) + puts "Fetching data from GitHub for #{repo} (as of #{date})" url = "https://api.github.com/repos/splitwise/#{repo}" JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count'] end - def growing_fast?(_method_name, method_args) + def growing_fast?(_method_name, method_args, **) method_args.first == 'cacheable' end end a = GitHubApiAdapter.new a.star_count('tokenautocomplete') -# Fetching data from GitHub for tokenautocomplete -# => 1164 +# Fetching data from GitHub for tokenautocomplete (as of 2026-02-26) +# => 1309 a.star_count('tokenautocomplete') -# => 1164 +# => 1309 a.star_count('cacheable') -# Fetching data from GitHub for cacheable -# => 19 +# Fetching data from GitHub for cacheable (as of 2026-02-26) +# => 58 a.star_count('cacheable') -# Fetching data from GitHub for cacheable -# => 19 +# Fetching data from GitHub for cacheable (as of 2026-02-26) +# => 58 diff --git a/examples/custom_key_example.rb b/examples/custom_key_example.rb index 3ef669c..950d889 100644 --- a/examples/custom_key_example.rb +++ b/examples/custom_key_example.rb @@ -5,12 +5,13 @@ class GitHubApiAdapter include Cacheable - cacheable :star_count, key_format: ->(target, method_name, method_args) do - [target.class, method_name, method_args.first, Time.now.strftime('%Y-%m-%d')].join('/') + cacheable :star_count, key_format: ->(target, method_name, method_args, **kwargs) do + date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d')) + [target.class, method_name, method_args.first, date].join('/') end - def star_count(repo) - puts "Fetching data from GitHub for #{repo}" + def star_count(repo, date: Time.now.strftime('%Y-%m-%d')) + puts "Fetching data from GitHub for #{repo} (as of #{date})" url = "https://api.github.com/repos/splitwise/#{repo}" JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count'] @@ -19,12 +20,12 @@ def star_count(repo) a = GitHubApiAdapter.new a.star_count('cacheable') -# Fetching data from GitHub for cacheable -# => 19 +# Fetching data from GitHub for cacheable (as of 2026-02-26) +# => 58 a.star_count('cacheable') -# => 19 +# => 58 a.star_count('tokenautocomplete') -# Fetching data from GitHub for tokenautocomplete -# => 1164 +# Fetching data from GitHub for tokenautocomplete (as of 2026-02-26) +# => 1309 a.star_count('tokenautocomplete') -# => 1164 +# => 1309 diff --git a/lib/cacheable/method_generator.rb b/lib/cacheable/method_generator.rb index c2b47d8..71dd718 100644 --- a/lib/cacheable/method_generator.rb +++ b/lib/cacheable/method_generator.rb @@ -20,33 +20,32 @@ 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 + unless_proc = opts[:unless].is_a?(Symbol) ? opts[:unless].to_proc : opts[:unless] + const_get(method_interceptor_module_name).class_eval do - define_method(method_names[:key_format_method_name]) do |*args| - key_format_proc.call(self, original_method_name, args) + define_method(method_names[:key_format_method_name]) do |*args, **kwargs| + key_format_proc.call(self, original_method_name, args, **kwargs) end - define_method(method_names[:clear_cache_method_name]) do |*args| - Cacheable.cache_adapter.delete(__send__(method_names[:key_format_method_name], *args)) + define_method(method_names[:clear_cache_method_name]) do |*args, **kwargs| + Cacheable.cache_adapter.delete(__send__(method_names[:key_format_method_name], *args, **kwargs)) end - define_method(method_names[:without_cache_method_name]) do |*args| - original_method = method(original_method_name).super_method - original_method.call(*args) + 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| - Cacheable.cache_adapter.fetch(__send__(method_names[:key_format_method_name], *args), 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) + define_method(method_names[:with_cache_method_name]) do |*args, **kwargs, &block| + Cacheable.cache_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 + __send__(method_names[:without_cache_method_name], *args, **kwargs, &block) end end - define_method(original_method_name) do |*args| - unless_proc = opts[:unless].is_a?(Symbol) ? opts[:unless].to_proc : opts[:unless] - - if unless_proc&.call(self, original_method_name, args) - __send__(method_names[:without_cache_method_name], *args) + define_method(original_method_name) do |*args, **kwargs, &block| + if unless_proc&.call(self, original_method_name, args, **kwargs) + __send__(method_names[:without_cache_method_name], *args, **kwargs, &block) else - __send__(method_names[:with_cache_method_name], *args) + __send__(method_names[:with_cache_method_name], *args, **kwargs, &block) end end end @@ -54,7 +53,7 @@ def create_cacheable_methods(original_method_name, opts = {}) # rubocop:enable Metrics/AbcSize, Metrics/MethodLength def default_key_format - proc do |target, method_name, _method_args| + proc do |target, method_name, _method_args, **_kwargs| # By default, we omit the _method_args from the cache key because there is no acceptable default behavior class_name = (target.is_a?(Module) ? target.name : target.class.name) cache_key = target.respond_to?(:cache_key) ? target.cache_key : class_name diff --git a/spec/cacheable/cacheable_spec.rb b/spec/cacheable/cacheable_spec.rb index 85a8cfe..ef2d21e 100644 --- a/spec/cacheable/cacheable_spec.rb +++ b/spec/cacheable/cacheable_spec.rb @@ -131,6 +131,73 @@ end end + describe 'keyword arguments' do + it 'forwards keyword arguments to the original method' do + cacheable_class.class_eval do + define_method(:method_with_kwargs) do |name:, greeting: 'Hello'| + "#{greeting}, #{name}" + end + + cacheable :method_with_kwargs, key_format: proc { |_, _, args, **kwargs| [args, kwargs] } + end + + expect(cacheable_object.method_with_kwargs(name: 'World')).to eq('Hello, World') + expect(cacheable_object.method_with_kwargs(name: 'World', greeting: 'Hi')).to eq('Hi, World') + end + + it 'forwards keyword arguments when skipping cache' do + cacheable_class.class_eval do + define_method(:kwargs_no_cache) do |val:| + val + end + + cacheable :kwargs_no_cache, key_format: proc { |_, _, args, **kwargs| [args, kwargs] } + end + + expect(cacheable_object.kwargs_no_cache_without_cache(val: 42)).to eq(42) + end + + it 'forwards mixed positional and keyword arguments' do + cacheable_class.class_eval do + define_method(:mixed_args) do |pos, key:| + "#{pos}-#{key}" + end + + cacheable :mixed_args, key_format: proc { |_, _, args, **kwargs| [args, kwargs] } + end + + expect(cacheable_object.mixed_args('a', key: 'b')).to eq('a-b') + end + end + + describe 'block forwarding' do + it 'forwards blocks to the original method on cache miss' do + cacheable_class.class_eval do + define_method(:method_with_block) do |&block| + block.call('from cache miss') + end + + cacheable :method_with_block + end + + result = cacheable_object.method_with_block { |msg| "got: #{msg}" } + expect(result).to eq('got: from cache miss') + end + + it 'forwards blocks when skipping cache' do + cacheable_class.class_eval do + define_method(:block_no_cache) do |&block| + block.call('direct') + end + + cacheable :block_no_cache + end + + result = cacheable_object.block_no_cache_without_cache { |msg| "got: #{msg}" } + expect(result).to eq('got: direct') + end + end + describe 'interceptor module' do it 'has the public generated methods' do expect(cacheable_class.ancestors.first.instance_methods(false)).to include(cacheable_method, :"#{cacheable_method}_without_cache", :"#{cacheable_method}_with_cache", :"#{cacheable_method}_key_format") @@ -434,6 +501,20 @@ def cache_control_method expect(cacheable_object).to receive(inner_method).twice.and_call_original 2.times { cacheable_object.send(cache_depends_on_args, the_method_arg) } end + + it 'passes kwargs to the `unless` proc' do + cache_depends_on_kwargs = :cache_depends_on_kwargs + inner_method = cacheable_method_inner + cacheable_class.class_eval do + define_method(cache_depends_on_kwargs) do |force: false| # rubocop:disable Lint/UnusedBlockArgument + send inner_method + end + + cacheable cache_depends_on_kwargs, unless: proc { |_, _, _, force: false| force } + end + expect(cacheable_object).to receive(inner_method).twice.and_call_original + 2.times { cacheable_object.send(cache_depends_on_kwargs, force: true) } + end end describe 'on class methods' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9f5b8d8..5f87bc7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,4 @@ -require 'bundler' -Bundler.require(:test) - +require 'bundler/setup' require 'cacheable' # This file was generated by the `rspec --init` command. Conventionally, all From 9ec5616ed6d790b1187a2681a16858b4d0bdd859 Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Thu, 26 Feb 2026 09:38:01 -0500 Subject: [PATCH 02/10] Warn when default cache key format is used with arguments The default key format ignores method arguments, meaning different arguments return the same cached value. This adds a deprecation warning (once per method) when a cached method is called with arguments but uses the default key format, guiding users to provide a :key_format proc. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- lib/cacheable/method_generator.rb | 12 +++++++++-- spec/cacheable/cacheable_spec.rb | 33 +++++++++++++++++++++++++------ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ff694e6..b4ac9de 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Fetching data from GitHub #### Default -By default, Cacheable will construct a key in the format `[cache_key || class_name, method_name]` without using method arguments. +By default, Cacheable will construct a key in the format `[cache_key || class_name, method_name]` without using method arguments. If a cached method is called with arguments while using the default key format, Cacheable will emit a warning to stderr since different arguments will return the same cached value. To silence the warning, provide a `:key_format` proc that includes the arguments in the cache key. If the object responds to `cache_key` its return value will be the first element in the array. `ActiveRecord` provides [`cache_key`](https://api.rubyonrails.org/classes/ActiveRecord/Integration.html#method-i-cache_key) but it can be added to any Ruby object or overwritten. If the object does not respond to it, the name of the class will be used instead. The second element will be the name of the method as a symbol. diff --git a/lib/cacheable/method_generator.rb b/lib/cacheable/method_generator.rb index 71dd718..ef96c57 100644 --- a/lib/cacheable/method_generator.rb +++ b/lib/cacheable/method_generator.rb @@ -53,8 +53,16 @@ def create_cacheable_methods(original_method_name, opts = {}) # rubocop:enable Metrics/AbcSize, Metrics/MethodLength def default_key_format - proc do |target, method_name, _method_args, **_kwargs| - # By default, we omit the _method_args from the cache key because there is no acceptable default behavior + warned = false + + proc do |target, method_name, method_args, **kwargs| + if !warned && (!method_args.empty? || !kwargs.empty?) + warn "Cacheable WARNING: '#{method_name}' is using the default key format but was called with " \ + 'arguments. Arguments are NOT included in the cache key, so different arguments will return ' \ + 'the same cached value. Provide a :key_format proc to include arguments in the cache key.' + warned = true + end + class_name = (target.is_a?(Module) ? target.name : target.class.name) cache_key = target.respond_to?(:cache_key) ? target.cache_key : class_name [cache_key, method_name].compact diff --git a/spec/cacheable/cacheable_spec.rb b/spec/cacheable/cacheable_spec.rb index ef2d21e..f01ac15 100644 --- a/spec/cacheable/cacheable_spec.rb +++ b/spec/cacheable/cacheable_spec.rb @@ -43,7 +43,7 @@ arg = 'an argument' expect(cacheable_object).to receive(cacheable_method_inner).with(arg) - cacheable_object.send(cacheable_method, arg) + expect { cacheable_object.send(cacheable_method, arg) }.to output.to_stderr end it 'creates a method that can skip the cache' do @@ -223,9 +223,28 @@ .to change { described_class.cache_adapter.exist?(key) }.from(false).to(true) end - it 'does not use the arguments to the method to determine the cache key' do - args = [1] - expect(cacheable_object.cacheable_method_key_format(*args)).to eq([cacheable_method]) + it 'does not use positional arguments in the cache key and warns' do + cache_key = nil + expect { cache_key = cacheable_object.cacheable_method_key_format(1) } + .to output(/default key format.*arguments are NOT included/i).to_stderr + expect(cache_key).to eq([cacheable_method]) + end + + it 'does not use keyword arguments in the cache key and warns' do + cache_key = nil + expect { cache_key = cacheable_object.cacheable_method_key_format(foo: 1) } + .to output(/default key format.*arguments are NOT included/i).to_stderr + expect(cache_key).to eq([cacheable_method]) + end + + it 'only warns once per method' do + expect { cacheable_object.cacheable_method_key_format(1) } + .to output(/default key format/i).to_stderr + expect { cacheable_object.cacheable_method_key_format(2) }.not_to output.to_stderr + end + + it 'does not warn when called without arguments' do + expect { cacheable_object.cacheable_method_key_format }.not_to output.to_stderr end it 'uses different keys for different cached values' do @@ -244,8 +263,10 @@ expect(cacheable_object).to receive(inner_method).with(arg1).once.and_call_original expect(cacheable_object).to receive(inner_method).with(arg2).once.and_call_original - 2.times { expect(cacheable_object.send(cacheable_method, arg1)).to include(arg1) } - 2.times { expect(cacheable_object.send(another_cacheable_method, arg2)).to include(arg2) } + expect do + 2.times { expect(cacheable_object.send(cacheable_method, arg1)).to include(arg1) } + 2.times { expect(cacheable_object.send(another_cacheable_method, arg2)).to include(arg2) } + end.to output.to_stderr end it 'uses the value of `cache_key` if the method is defined instead of the class' do From 8c488bffea1032f74f4459c8f2992f1c60e727d7 Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Thu, 26 Feb 2026 09:42:16 -0500 Subject: [PATCH 03/10] Make MemoryAdapter thread-safe with Monitor The MemoryAdapter used a plain Hash with no synchronization, causing race conditions in multi-threaded servers (Puma, Sidekiq). fetch did a non-atomic exist?-then-write, allowing concurrent threads to duplicate work or corrupt state. Changes: - Wrap all cache operations with Monitor#synchronize - Make fetch atomic (inline key check + write under one lock) - Add frozen_string_literal pragma - Update tests to assert cache state directly instead of mocking internal write calls Co-Authored-By: Claude Opus 4.6 --- .../cache_adapters/memory_adapter.rb | 36 +++++++++++-------- spec/cacheable/cacheable_spec.rb | 12 +++++-- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/lib/cacheable/cache_adapters/memory_adapter.rb b/lib/cacheable/cache_adapters/memory_adapter.rb index 60129b3..59b8104 100644 --- a/lib/cacheable/cache_adapters/memory_adapter.rb +++ b/lib/cacheable/cache_adapters/memory_adapter.rb @@ -1,42 +1,50 @@ +# frozen_string_literal: true + +require 'monitor' + module Cacheable module CacheAdapters class MemoryAdapter def initialize + @monitor = Monitor.new clear end def read(key) - cache[key] + @monitor.synchronize { @cache[key] } end def write(key, value) - cache[key] = value + @monitor.synchronize { @cache[key] = value } end def exist?(key) - cache.key?(key) + @monitor.synchronize { @cache.key?(key) } end + # NOTE: yield is intentionally called inside the lock to prevent thundering herd — only one thread + # computes a missing value while others wait. This is acceptable for a simple in-memory adapter; + # production use cases needing high concurrency should use a real cache backend via CacheAdapter. def fetch(key, _options = {}) - return read(key) if exist?(key) + @monitor.synchronize do + return @cache[key] if @cache.key?(key) - write(key, yield) + @cache[key] = yield + end end - def delete(key) # rubocop:disable Naming/PredicateMethod -- mimics the ActiveSupport::Cache::Store#delete interface and isn't a predicate - return false unless exist?(key) + def delete(key) + @monitor.synchronize do + return false unless @cache.key?(key) - cache.delete key - true + @cache.delete(key) + true + end end def clear - @cache = {} + @monitor.synchronize { @cache = {} } end - - private - - attr_reader :cache end end end diff --git a/spec/cacheable/cacheable_spec.rb b/spec/cacheable/cacheable_spec.rb index f01ac15..57e1164 100644 --- a/spec/cacheable/cacheable_spec.rb +++ b/spec/cacheable/cacheable_spec.rb @@ -366,8 +366,10 @@ cacheable(*local_variable_so_class_eval_works) end - expect(described_class.cache_adapter).to receive(:write).twice.and_call_original 2.times { cache_methods.each { |method| cacheable_object.send(method) } } + cache_methods.each do |method| + expect(described_class.cache_adapter.exist?([method])).to be true + end end it 'uses the same options for cacheable methods declared on a single line' do @@ -377,8 +379,10 @@ cacheable(*local_variable_so_class_eval_works, unless: proc { true }) end - expect(described_class.cache_adapter).not_to receive(:write) 2.times { cache_methods.each { |method| cacheable_object.send(method) } } + cache_methods.each do |method| + expect(described_class.cache_adapter.exist?([method])).to be false + end end it 'can take strings' do @@ -388,8 +392,10 @@ cacheable(*cache_methods_as_strings) end - expect(described_class.cache_adapter).to receive(:write).twice.and_call_original 2.times { cache_methods.each { |method| cacheable_object.send(method) } } + cache_methods_as_strings.each do |method| + expect(described_class.cache_adapter.exist?([method])).to be true + end end it 'can take strings before the method is defined' do From f7f6cba47bc345a01ec1203600f246864c33f3bb Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Thu, 26 Feb 2026 09:43:51 -0500 Subject: [PATCH 04/10] Support per-class cache adapter configuration Previously all classes shared a single global Cacheable.cache_adapter. Now each class that includes Cacheable can set its own adapter via MyClass.cache_adapter=, falling back to the global adapter when not configured. Changes: - Extend including classes with CacheAdapter - CacheAdapter#cache_adapter falls back to Cacheable.cache_adapter when no class-level adapter is set - Generated methods resolve adapter from the class instead of referencing Cacheable directly Co-Authored-By: Claude Opus 4.6 --- README.md | 20 ++++++++++++++++++++ lib/cacheable.rb | 1 + lib/cacheable/cache_adapter.rb | 4 ++-- lib/cacheable/method_generator.rb | 10 ++++++---- spec/cacheable/cacheable_spec.rb | 31 +++++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b4ac9de..c36a129 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,26 @@ If your cache backend supports options, you can pass them as the `cache_options: cacheable :with_options, cache_options: {expires_in: 3_600} ``` +### 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: + +```ruby +class FrequentlyAccessedModel + include Cacheable + + self.cache_adapter = MyFasterCache.new + + cacheable :expensive_lookup + + def expensive_lookup + # ... + end +end +``` + +The class-level adapter takes precedence over the global adapter. Classes without their own adapter fall back to `Cacheable.cache_adapter` as usual. + ### Flexible Options You can use the same options with multiple cache methods or limit them only to specific methods: diff --git a/lib/cacheable.rb b/lib/cacheable.rb index e648914..83f4605 100644 --- a/lib/cacheable.rb +++ b/lib/cacheable.rb @@ -32,6 +32,7 @@ module Cacheable extend CacheAdapter def self.included(base) + base.extend(Cacheable::CacheAdapter) base.extend(Cacheable::MethodGenerator) interceptor_name = base.send(:method_interceptor_module_name) diff --git a/lib/cacheable/cache_adapter.rb b/lib/cacheable/cache_adapter.rb index 1adf9c0..96eb518 100644 --- a/lib/cacheable/cache_adapter.rb +++ b/lib/cacheable/cache_adapter.rb @@ -7,11 +7,11 @@ module CacheAdapter def self.extended(base) base.instance_variable_set(:@_cache_adapter, nil) - base.cache_adapter = DEFAULT_ADAPTER + base.cache_adapter = DEFAULT_ADAPTER if base == Cacheable end def cache_adapter - @_cache_adapter + @_cache_adapter || (self == Cacheable ? nil : Cacheable.cache_adapter) end def cache_adapter=(name_or_adapter) diff --git a/lib/cacheable/method_generator.rb b/lib/cacheable/method_generator.rb index ef96c57..3a5f639 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/MethodLength + # rubocop:disable Metrics/AbcSize, 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,7 +28,8 @@ def create_cacheable_methods(original_method_name, opts = {}) end define_method(method_names[:clear_cache_method_name]) do |*args, **kwargs| - Cacheable.cache_adapter.delete(__send__(method_names[:key_format_method_name], *args, **kwargs)) + adapter = (is_a?(Module) ? singleton_class : self.class).cache_adapter + adapter.delete(__send__(method_names[:key_format_method_name], *args, **kwargs)) end define_method(method_names[:without_cache_method_name]) do |*args, **kwargs, &block| @@ -36,7 +37,8 @@ def create_cacheable_methods(original_method_name, opts = {}) end define_method(method_names[:with_cache_method_name]) do |*args, **kwargs, &block| - Cacheable.cache_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 + 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 __send__(method_names[:without_cache_method_name], *args, **kwargs, &block) end end @@ -50,7 +52,7 @@ def create_cacheable_methods(original_method_name, opts = {}) end end end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + # rubocop:enable Metrics/AbcSize, 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 57e1164..c650b0b 100644 --- a/spec/cacheable/cacheable_spec.rb +++ b/spec/cacheable/cacheable_spec.rb @@ -558,6 +558,37 @@ def cache_control_method end end + describe 'per-class cache adapter' do + it 'falls back to the global adapter by default' do + expect(cacheable_class.cache_adapter).to eq(described_class.cache_adapter) + end + + it 'allows setting a class-specific adapter' do + class_adapter = Cacheable::CacheAdapters::MemoryAdapter.new + cacheable_class.cache_adapter = class_adapter + + expect(cacheable_class.cache_adapter).to eq(class_adapter) + expect(cacheable_class.cache_adapter).not_to eq(described_class.cache_adapter) + end + + it 'uses the class adapter for caching when set' do + class_adapter = Cacheable::CacheAdapters::MemoryAdapter.new + cacheable_class.cache_adapter = class_adapter + + cacheable_object.send(cacheable_method) + expect(class_adapter.exist?([cacheable_method])).to be true + expect(described_class.cache_adapter.exist?([cacheable_method])).to be false + end + + it 'does not affect other classes' do + other_class = Class.new.tap { |klass| klass.class_exec(&class_definition) } + class_adapter = Cacheable::CacheAdapters::MemoryAdapter.new + cacheable_class.cache_adapter = class_adapter + + expect(other_class.cache_adapter).to eq(described_class.cache_adapter) + end + end + it 'passes `cache_options` to the cache client' do cache_options = {expires_in: 3_600} cache_method_with_cache_options = :cache_method_with_cache_options From 41e81189263a35da32fa463a03fad09f38593d91 Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Thu, 26 Feb 2026 09:48:01 -0500 Subject: [PATCH 05/10] Stop polluting Cacheable namespace with interceptor constants Previously, each class that included Cacheable created a constant on the Cacheable module (e.g. Cacheable::MyClassCacher) via const_set. This polluted the namespace and leaked memory for anonymous classes. Re-including Cacheable used remove_const which left ghost modules in the MRO. Now interceptor modules are stored as instance variables on the including class (@_cacheable_interceptor). The module gets a descriptive to_s/inspect for debugging without creating constants. Co-Authored-By: Claude Opus 4.6 --- lib/cacheable.rb | 8 +++++--- lib/cacheable/method_generator.rb | 2 +- spec/cacheable/cacheable_spec.rb | 13 ++++--------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/lib/cacheable.rb b/lib/cacheable.rb index 83f4605..450b1ea 100644 --- a/lib/cacheable.rb +++ b/lib/cacheable.rb @@ -36,8 +36,10 @@ def self.included(base) base.extend(Cacheable::MethodGenerator) interceptor_name = base.send(:method_interceptor_module_name) - remove_const(interceptor_name) if const_defined?(interceptor_name) - - base.prepend const_set(interceptor_name, Module.new) + interceptor = Module.new + interceptor.define_singleton_method(:to_s) { interceptor_name } + interceptor.define_singleton_method(:inspect) { interceptor_name } + base.instance_variable_set(:@_cacheable_interceptor, interceptor) + base.prepend interceptor end end diff --git a/lib/cacheable/method_generator.rb b/lib/cacheable/method_generator.rb index 3a5f639..23c200d 100644 --- a/lib/cacheable/method_generator.rb +++ b/lib/cacheable/method_generator.rb @@ -22,7 +22,7 @@ def create_cacheable_methods(original_method_name, opts = {}) unless_proc = opts[:unless].is_a?(Symbol) ? opts[:unless].to_proc : opts[:unless] - const_get(method_interceptor_module_name).class_eval do + @_cacheable_interceptor.class_eval do define_method(method_names[:key_format_method_name]) do |*args, **kwargs| key_format_proc.call(self, original_method_name, args, **kwargs) end diff --git a/spec/cacheable/cacheable_spec.rb b/spec/cacheable/cacheable_spec.rb index c650b0b..dcec584 100644 --- a/spec/cacheable/cacheable_spec.rb +++ b/spec/cacheable/cacheable_spec.rb @@ -89,22 +89,17 @@ end it 'uses the class name to define an interceptor module' do - # This is done specifically this way to be compatible w/ RSpec best practices - # Once Cacheable is included in a class, it uses the name of the class to define the - # interceptor module. However, it is considered bad practice to create constants in RSpec - # so they're typically made with `stub_const`. We need to include Cacheable after the - # anonymous class has been created and assigned to the stubbed constant for this order to work. stub_const('RealClassName', Class.new) - class_name = RealClassName.include(described_class) + RealClassName.include(described_class) - expect(class_name.ancestors.map(&:to_s)).to include("Cacheable::#{class_name}Cacher") + expect(RealClassName.ancestors.map(&:to_s)).to include('RealClassNameCacher') end it 'uses the class address to define an interceptor module for anonymous classes' do custom_class = Class.new { include Cacheable } class_name = custom_class.to_s.tr('#:<>', '') - expect(custom_class.ancestors.map(&:to_s)).to include("Cacheable::#{class_name}Cacher") + expect(custom_class.ancestors.map(&:to_s)).to include("#{class_name}Cacher") end context 'when the method name has special characters' do @@ -126,7 +121,7 @@ stub_const('Outer::Inner', Class.new) Outer::Inner.include(described_class) - expect(Outer::Inner.ancestors.map(&:to_s)).to include('Cacheable::OuterInnerCacher') + expect(Outer::Inner.ancestors.map(&:to_s)).to include('OuterInnerCacher') end end end From 8dc4fb06a572c84761840ae80a9accaa2f3a27aa Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Thu, 26 Feb 2026 09:48:55 -0500 Subject: [PATCH 06/10] Ignore dev specific platforms --- Gemfile.lock | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index cab1bbf..9e2ed2a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,9 +14,7 @@ GEM reline (>= 0.6.0) coderay (1.1.3) diff-lcs (1.6.2) - ffi (1.17.3-arm64-darwin) - ffi (1.17.3-x86_64-darwin) - ffi (1.17.3-x86_64-linux-gnu) + ffi (1.17.3) formatador (1.2.3) reline guard (2.20.1) @@ -118,10 +116,7 @@ GEM unicode-emoji (4.2.0) PLATFORMS - arm64-darwin-23 - x86_64-darwin-20 - x86_64-darwin-21 - x86_64-linux + ruby DEPENDENCIES cacheable! From 44949f558c46a6e29c3bc73e3ead2dbec81a2cfa Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Thu, 26 Feb 2026 09:16:00 -0500 Subject: [PATCH 07/10] Add CLAUDE.md for Claude Code guidance Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1b5b52f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Cacheable is a Ruby gem by Splitwise that adds method caching via an AOP (Aspect-Oriented Programming) pattern. Include `Cacheable` in a class, annotate methods with `cacheable :method_name`, and results are automatically cached. + +## Commands + +```bash +# Install dependencies +bundle install + +# Run full default task (rubocop + rspec) +bundle exec rake + +# Run tests only +bundle exec rspec + +# Run a single test file +bundle exec rspec spec/cacheable/cacheable_spec.rb + +# Run a single test by line number +bundle exec rspec spec/cacheable/cacheable_spec.rb:45 + +# Run linter only +bundle exec rubocop + +# Auto-fix lint issues +bundle exec rubocop -a + +# Watch and auto-run tests/lint on file changes +bundle exec guard +``` + +## Architecture + +The gem uses **module prepending with dynamic method generation** to intercept and cache method calls. + +### Core flow + +1. **`Cacheable`** (`lib/cacheable.rb`) — The module users include. On `included`, it extends the host class with `MethodGenerator` and creates a unique anonymous interceptor module that gets prepended to the class. + +2. **`MethodGenerator`** (`lib/cacheable/method_generator.rb`) — When `cacheable :method_name` is called, this generates five methods on the interceptor module: + - `method_name` (override) — dispatcher that routes to `with_cache` or `without_cache` based on the `unless:` condition + - `method_with_cache` — fetch from cache or compute and store + - `method_without_cache` — bypass cache, call original + - `method_key_format` — generate the cache key + - `clear_method_cache` — invalidate the cache entry + +3. **`CacheAdapter`** (`lib/cacheable/cache_adapter.rb`) — Protocol for cache backends. Default is `:memory`. Required interface: `fetch(key, options, &block)` and `delete(key)`. + +4. **`MemoryAdapter`** (`lib/cacheable/cache_adapters/memory_adapter.rb`) — Built-in hash-backed in-memory cache. Production use typically wires in `Rails.cache` or a custom adapter. + +### Key design details + +- Each class that includes `Cacheable` gets its own unique interceptor module (created via `Module.new`), which is prepended to the class. This is how `super` chains through to the original method. +- The `unless:` option accepts a proc/symbol that, when truthy, skips caching and calls the original method directly. +- `key_format:` accepts a proc receiving `(target, method_name, args, **kwargs)` for custom cache key generation. + +## Style + +- Max line length: 120 characters +- Max method length: 25 lines +- Rubocop enforced with `NewCops: enable` +- No frozen string literal comments required From 0464167b5f13c85daa84787429836f29a42c139a Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Thu, 26 Feb 2026 23:35:05 -0500 Subject: [PATCH 08/10] Update star counts in examples and README Co-Authored-By: Claude Opus 4.6 --- README.md | 24 ++++++++++++------------ examples/class_method_example.rb | 8 ++++---- examples/simple_example.rb | 10 +++++----- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c36a129..f845d44 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,9 @@ end > a = GitHubApiAdapter.new > a.star_count Fetching data from GitHub - => 19 + => 58 > a.star_count - => 19 + => 58 # Notice that "Fetching data from GitHub" was not output the 2nd time the method was invoked. # The network call and result parsing would also not be performed again. @@ -102,12 +102,12 @@ The cache can intentionally be skipped by appending `_without_cache` to the meth > a = GitHubApiAdapter.new > a.star_count Fetching data from GitHub - => 19 + => 58 > a.star_count_without_cache Fetching data from GitHub - => 19 + => 58 > a.star_count - => 19 + => 58 ``` #### Remove the Value via `clear_#{method}_cache` @@ -118,15 +118,15 @@ The cached value can be cleared at any time by calling `clear_#{your_method_name > a = GitHubApiAdapter.new > a.star_count Fetching data from GitHub - => 19 + => 58 > a.star_count - => 19 + => 58 > a.clear_star_count_cache => true > a.star_count Fetching data from GitHub - => 19 + => 58 ``` ## Additional Configuration @@ -325,15 +325,15 @@ end ```irb > GitHubApiAdapter.star_count_for_cacheable Fetching data from GitHub for cacheable - => 19 + => 58 > GitHubApiAdapter.star_count_for_cacheable - => 19 + => 58 > GitHubApiAdapter.star_count_for_tokenautocomplete Fetching data from GitHub for tokenautocomplete - => 1164 + => 1309 > GitHubApiAdapter.star_count_for_tokenautocomplete - => 1164 + => 1309 ``` ### Other Notes / Frequently Asked Questions diff --git a/examples/class_method_example.rb b/examples/class_method_example.rb index 5d23e0a..b466b2f 100644 --- a/examples/class_method_example.rb +++ b/examples/class_method_example.rb @@ -29,12 +29,12 @@ def self.star_count_for_tokenautocomplete GitHubApiAdapter.star_count_for_cacheable # Fetching data from GitHub for cacheable -# => 19 +# => 58 GitHubApiAdapter.star_count_for_cacheable -# => 19 +# => 58 GitHubApiAdapter.star_count_for_tokenautocomplete # Fetching data from GitHub for tokenautocomplete -# => 1164 +# => 1309 GitHubApiAdapter.star_count_for_tokenautocomplete -# => 1164 +# => 1309 diff --git a/examples/simple_example.rb b/examples/simple_example.rb index 47606d6..b5fac8e 100644 --- a/examples/simple_example.rb +++ b/examples/simple_example.rb @@ -18,18 +18,18 @@ def star_count a = GitHubApiAdapter.new a.star_count # Fetching data from GitHub -# => 19 +# => 58 a.star_count -# => 19 +# => 58 a.star_count_without_cache # Fetching data from GitHub -# => 19 +# => 58 a.star_count -# => 19 +# => 58 a.clear_star_count_cache # => true a.star_count # Fetching data from GitHub -# => 19 +# => 58 From 79e3971c931f3ca6ab12d362789f234aae288b8d Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Thu, 26 Feb 2026 23:45:12 -0500 Subject: [PATCH 09/10] Bump version to 2.1.0 Co-Authored-By: Claude Opus 4.6 --- Gemfile.lock | 2 +- lib/cacheable/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9e2ed2a..e064fa6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - cacheable (2.0.0) + cacheable (2.1.0) GEM remote: https://rubygems.org/ diff --git a/lib/cacheable/version.rb b/lib/cacheable/version.rb index 68d6e8a..25d2219 100644 --- a/lib/cacheable/version.rb +++ b/lib/cacheable/version.rb @@ -3,7 +3,7 @@ module Cacheable module VERSION MAJOR = 2 - MINOR = 0 + MINOR = 1 TINY = 0 PRE = nil From 4eb4a460d39dbe1afe5af59bbeff6bfe4f9f9ee1 Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Fri, 27 Feb 2026 09:32:54 -0500 Subject: [PATCH 10/10] Disable RuboCop extension suggestions The only suggestion is rubocop-rake which adds no value for our trivial Rakefile. Co-Authored-By: Claude Opus 4.6 --- .rubocop.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.rubocop.yml b/.rubocop.yml index 79bbe90..518efe9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,6 +3,7 @@ plugins: AllCops: NewCops: enable + SuggestExtensions: false # rubocop-rake is the only suggestion and our Rakefile is trivial boilerplate TargetRubyVersion: 3.3 Layout/LineLength: