diff --git a/CHANGELOG.md b/CHANGELOG.md index edbcc18..42c544d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min ### Added +- `cache_bust: callable` option on `memoize` — automatic cache invalidation driven by a version token. A callable (Proc, lambda, or Symbol naming an instance method) is invoked on the instance at every cache lookup; the returned token is folded into the cache key alongside the normal arguments. When the token changes (e.g. an ActiveRecord `updated_at` advances after a `save`), the old key no longer matches any entry — the method body is recomputed and stored under the new key without any explicit `reset_memo` call. Accepts a zero-argument callable invoked via `instance_exec` (giving access to `self`, instance variables, and methods) or a `Symbol` naming an instance method. Returns any comparable value as the token: a `Time`, `Integer`, `String`, `Array`, etc. Old token entries accumulate as stale; pair with `ttl:` or a store adapter's eviction to bound memory. Incompatible with `key:`. Composes with `namespace:`, `ttl:`, `if:`, `unless:`, and `shared_cache:`. + - `shared_cache: "name"` option on `memoize` — routes all reads and writes through a globally-registered named `Stores::Base` instance, enabling cross-class cache sharing. Any number of unrelated classes can share the same backing store by referencing the same name. The store is resolved at `memoize` definition time via `SafeMemoize.shared_cache("name")`, which auto-creates a `Stores::Memory` instance on first access; supply a custom adapter (Redis, RailsCache, etc.) by calling `SafeMemoize.register_shared_cache("name", store)` before any class that references the name is loaded. Incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:`; composes naturally with `namespace:`, `ttl:`, `if:`, `unless:`, and `key:`. - `SafeMemoize.shared_cache(name)` — returns the `Stores::Base` instance for the given name, creating a new `Stores::Memory` if none is registered. - `SafeMemoize.register_shared_cache(name, store)` — registers a custom `Stores::Base` instance under a name; must be called before any class that uses that name via `shared_cache:` is loaded. diff --git a/README.md b/README.md index cfe45c4..22a0ab4 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name - [Ractor-safe shared cache via `ractor_safe: true` — supervisor Ractor replaces the Mutex; worker Ractors can call the memoized method directly](#ractor-safe-shared-cache) - [Cache namespacing — per-method `namespace:`, class-level `.safe_memoize_namespace=`, and global `Configuration#namespace` for multi-tenant and versioned deployments](#cache-namespacing) - [Named shared caches via `shared_cache: "name"` — cross-class cache sharing backed by a globally-registered store](#named-shared-caches) +- [Automatic cache busting via `cache_bust:` — version-token-based invalidation; works with ActiveRecord `updated_at` and any comparable value](#automatic-cache-busting) ## Installation @@ -731,6 +732,67 @@ Metrics are per-instance and reset independently from the cache itself — clear [↑ Back to features](#features) +### Automatic cache busting + +`cache_bust:` ties a method's cache lifetime to a version token derived from instance state. When the token changes, the old cache key no longer matches — the method is recomputed automatically, with no explicit `reset_memo` required. + +```ruby +class OrderDecorator + prepend SafeMemoize + + def initialize(order) + @order = order + end + + def summary = expensive_compute(@order) + memoize :summary, cache_bust: -> { @order.updated_at } + # Saving @order advances updated_at → next call is a cache miss → fresh result +end +``` + +#### Token forms + +```ruby +# Proc/lambda — instance_exec gives full access to self, ivars, and methods +memoize :report, cache_bust: -> { @record.updated_at } + +# Symbol — calls the named instance method +memoize :data, cache_bust: :cache_version + +# Compound token — any comparable value works, including arrays +memoize :stats, cache_bust: -> { [@version, tenant_id] } +``` + +#### How it works + +The token is incorporated into the cache key alongside the normal arguments. When the token changes, the old key simply produces no match — there is no deletion. Stale entries accumulate silently until: +- They expire via `ttl:`, or +- They are evicted by the store adapter's own eviction policy, or +- You call `reset_memo(:method_name)` or `reset_all_memos` explicitly. + +For unbounded caches, pair with `ttl:` or a `max_size:`-capable store to limit memory growth: + +```ruby +memoize :summary, cache_bust: -> { @order.updated_at }, ttl: 3600 +``` + +#### Introspection + +All introspection methods work with the **current** token: + +```ruby +obj.memoized?(:summary) # true only if the current token's entry is live +obj.memo_count(:summary) # counts ALL live versions (current + stale) +obj.reset_memo(:summary) # clears ALL versions +``` + +#### Constraints + +- Incompatible with `key:` — both define the cache key shape; raises `ArgumentError` at `memoize` time. +- Composes with `namespace:`, `ttl:`, `if:`, `unless:`, and `shared_cache:`. + +[↑ Back to features](#features) + ### Named shared caches `shared_cache: "name"` routes all cache reads and writes through a globally-registered store, letting unrelated classes share the same cached data without any object-level coordination. @@ -1379,6 +1441,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem | `ractor_safe:` | `Boolean` | `false` | Supervisor-Ractor shared cache; replaces the `Mutex`; worker Ractors can call the method; requires `shared: true`; cached values are deep-frozen; incompatible with `if:`, `unless:`, `max_size:`, `ttl_refresh:`, `key:`, and `store:` | | `namespace:` | `String \| nil` | `nil` | Namespace prefix prepended to the cache key's first element; must not contain `:`; takes precedence over the class-level and global namespace | | `shared_cache:` | `String \| nil` | `nil` | Name of a globally-registered shared store; incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` | +| `cache_bust:` | `Proc \| Symbol \| nil` | `nil` | Version-token callable; invoked on the instance at each lookup; token is folded into the key; incompatible with `key:` | ### `memoize_all` options (class method) diff --git a/ROADMAP.md b/ROADMAP.md index 1f96073..876d740 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey | DSL refinements | Evaluate alternative syntax proposals (`memoize_method`, block form, annotation approach) based on community feedback; introduce the preferred form with a migration path from the current API | Planned | | Cross-instance cache sharing | Beyond the class-level `shared: true`, support explicitly named shared caches that span unrelated classes | Shipped | | Cache namespacing | Allow a namespace prefix on all keys for multi-tenant or versioned deployments (especially useful with external stores) | Shipped | -| Automatic cache busting | Optional integration with ActiveRecord's `updated_at` timestamp so object mutations automatically invalidate their own cached entries | Planned | +| Automatic cache busting | Optional integration with ActiveRecord's `updated_at` timestamp so object mutations automatically invalidate their own cached entries | Shipped | --- diff --git a/lib/safe_memoize/class_methods.rb b/lib/safe_memoize/class_methods.rb index d7284b0..0c7451d 100644 --- a/lib/safe_memoize/class_methods.rb +++ b/lib/safe_memoize/class_methods.rb @@ -43,6 +43,14 @@ module ClassMethods # {#safe_memoize_namespace} and the global {SafeMemoize::Configuration#namespace}. # Useful for versioning a single method independently of its peers. Must not contain # the character +:+. + # @param cache_bust [Proc, Symbol, nil] callable invoked on the instance (via + # +instance_exec+) on every cache lookup to obtain a version token. The token is + # folded into the cache key alongside the normal arguments, so when the token + # changes (e.g. an ActiveRecord +updated_at+ timestamp advances after a +save+) + # the old key no longer matches any entry — the method is recomputed and the result + # stored under the new key. Accepts any callable (+Proc+, +lambda+, +Method+) that + # takes no arguments, or a +Symbol+ naming an instance method. Cannot be combined + # with +key:+. # @param shared_cache [String, nil] name of a globally-registered shared cache store # (see {SafeMemoize.shared_cache} and {SafeMemoize.register_shared_cache}). All # instances of any class that memoizes a method with the same +shared_cache:+ name @@ -68,7 +76,7 @@ module ClassMethods # @example With a custom store # STORE = SafeMemoize::Stores::Memory.new # memoize :fetch, store: STORE, ttl: 300 - def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false, namespace: nil, shared_cache: nil) + def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false, namespace: nil, shared_cache: nil, cache_bust: nil) method_name = method_name.to_sym unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name) @@ -115,6 +123,13 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call) raise ArgumentError, ":key must be callable" if key && !key.respond_to?(:call) + if cache_bust + unless cache_bust.respond_to?(:call) || cache_bust.is_a?(Symbol) + raise ArgumentError, "cache_bust: must be a callable or Symbol (got #{cache_bust.class})" + end + raise ArgumentError, "cache_bust: and key: cannot be combined" if key + end + if store raise ArgumentError, "store: must be a SafeMemoize::Stores::Base instance (got #{store.class})" unless store.is_a?(SafeMemoize::Stores::Base) raise ArgumentError, "max_size: is not supported with store: — use the store adapter's own eviction" if max_size @@ -174,6 +189,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u end __safe_memo_class_key_generators__[method_name] = key if key + __safe_memo_class_cache_bust_generators__[method_name] = cache_bust if cache_bust # Normalize to a single "should cache?" predicate condition = if cond_if @@ -666,6 +682,10 @@ def __safe_memo_method_namespaces__ @__safe_memo_method_namespaces__ ||= {} end + def __safe_memo_class_cache_bust_generators__ + @__safe_memo_class_cache_bust_generators__ ||= {} + end + # Resolves the effective first-element key sym for a given bare method name, # applying the active namespace. Used by class-level cache operations where # instance methods (compute_cache_key) are unavailable. diff --git a/lib/safe_memoize/custom_key_methods.rb b/lib/safe_memoize/custom_key_methods.rb index d5f756d..1475ed5 100644 --- a/lib/safe_memoize/custom_key_methods.rb +++ b/lib/safe_memoize/custom_key_methods.rb @@ -29,7 +29,13 @@ def compute_cache_key(method_name, args, kwargs) if key_block [effective_name, key_block.call(*args, **kwargs)] else - safe_memo_cache_key(effective_name, args, kwargs) + bust_block = self.class.send(:__safe_memo_class_cache_bust_generators__)[method_name] + if bust_block + token = bust_block.is_a?(Symbol) ? send(bust_block) : instance_exec(&bust_block) + [effective_name, [deep_freeze_copy(args), deep_freeze_copy(kwargs), token]] + else + safe_memo_cache_key(effective_name, args, kwargs) + end end end diff --git a/sig/safe_memoize.rbs b/sig/safe_memoize.rbs index 2c084d9..f68357c 100644 --- a/sig/safe_memoize.rbs +++ b/sig/safe_memoize.rbs @@ -48,7 +48,7 @@ module SafeMemoize end module ClassMethods - def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?) -> void + def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil) -> void def safe_memoize_store: () -> Stores::Base? def safe_memoize_store=: (Stores::Base?) -> Stores::Base? def safe_memoize_namespace: () -> String? @@ -68,6 +68,7 @@ module SafeMemoize def __safe_memo_shared_lru_order__: () -> Hash[Symbol, Hash[memo_key, true]] def __safe_memo_class_key_generators__: () -> Hash[Symbol, Proc] def __safe_memo_method_namespaces__: () -> Hash[Symbol, String] + def __safe_memo_class_cache_bust_generators__: () -> Hash[Symbol, Proc | Symbol] def memoized_method_visibility: (Symbol method_name) -> Symbol end diff --git a/spec/cache_bust_spec.rb b/spec/cache_bust_spec.rb new file mode 100644 index 0000000..714b6ce --- /dev/null +++ b/spec/cache_bust_spec.rb @@ -0,0 +1,346 @@ +# frozen_string_literal: true + +RSpec.describe "SafeMemoize cache_bust:" do + after { SafeMemoize.reset_configuration! } + + # --------------------------------------------------------------------------- + # Basic invalidation behaviour + # --------------------------------------------------------------------------- + + describe "automatic cache busting" do + let(:klass) do + Class.new do + prepend SafeMemoize + + attr_accessor :version + + def initialize + @version = 1 + @calls = 0 + end + + def compute + @calls += 1 + "result_v#{@version}" + end + memoize :compute, cache_bust: -> { @version } + + def call_count = @calls + end + end + + it "caches the result when the token is unchanged" do + obj = klass.new + obj.compute + obj.compute + expect(obj.call_count).to eq(1) + end + + it "recomputes when the token changes" do + obj = klass.new + expect(obj.compute).to eq("result_v1") + obj.version = 2 + expect(obj.compute).to eq("result_v2") + expect(obj.call_count).to eq(2) + end + + it "reverts to the cached value when the token returns to a prior value" do + obj = klass.new + obj.compute # stores v1 result + obj.version = 2 + obj.compute # stores v2 result + obj.version = 1 + obj.compute # hits v1 cache entry + expect(obj.call_count).to eq(2) + end + end + + # --------------------------------------------------------------------------- + # Works with arguments + # --------------------------------------------------------------------------- + + describe "with method arguments" do + let(:klass) do + Class.new do + prepend SafeMemoize + + attr_accessor :version + + def initialize = (@version = 1 + @calls = 0) + + def fetch(id) + @calls += 1 + "#{id}@v#{@version}" + end + memoize :fetch, cache_bust: -> { @version } + + def call_count = @calls + end + end + + it "caches per unique (args, token) combination" do + obj = klass.new + obj.fetch(1) + obj.fetch(2) + obj.fetch(1) + expect(obj.call_count).to eq(2) + end + + it "treats same args with different token as distinct entries" do + obj = klass.new + obj.fetch(1) + obj.version = 2 + obj.fetch(1) + expect(obj.call_count).to eq(2) + end + end + + # --------------------------------------------------------------------------- + # Symbol form + # --------------------------------------------------------------------------- + + describe "Symbol form (instance method name)" do + let(:klass) do + Class.new do + prepend SafeMemoize + + attr_accessor :rev + + def initialize = (@rev = 0 + @calls = 0) + + def cache_version = @rev + + def data + @calls += 1 + "v#{@rev}" + end + memoize :data, cache_bust: :cache_version + + def call_count = @calls + end + end + + it "calls the named instance method to obtain the token" do + obj = klass.new + obj.data + obj.rev = 1 + obj.data + expect(obj.call_count).to eq(2) + end + + it "hits the cache when the version is unchanged" do + obj = klass.new + obj.data + obj.data + expect(obj.call_count).to eq(1) + end + end + + # --------------------------------------------------------------------------- + # Compound token (array) + # --------------------------------------------------------------------------- + + describe "compound token" do + it "incorporates multiple values into the version" do + klass = Class.new do + prepend SafeMemoize + + attr_accessor :v1, :v2 + + def initialize = (@v1 = 1 + @v2 = "a" + @calls = 0) + + def compute + @calls += 1 + end + memoize :compute, cache_bust: -> { [@v1, @v2] } + + def call_count = @calls + end + + obj = klass.new + obj.compute + obj.v1 = 2 + obj.compute + obj.v2 = "b" + obj.compute + expect(obj.call_count).to eq(3) + end + end + + # --------------------------------------------------------------------------- + # Introspection transparency + # --------------------------------------------------------------------------- + + describe "introspection" do + let(:klass) do + Class.new do + prepend SafeMemoize + + attr_accessor :version + + def initialize = (@version = 1) + + def value = 42 + memoize :value, cache_bust: -> { @version } + end + end + + it "memoized? returns true for the current token" do + obj = klass.new + obj.value + expect(obj.memoized?(:value)).to be true + end + + it "memoized? returns false after the token changes" do + obj = klass.new + obj.value + obj.version = 2 + expect(obj.memoized?(:value)).to be false + end + + it "memo_count counts all live versions" do + obj = klass.new + obj.value # v1 entry + obj.version = 2 + obj.value # v2 entry + expect(obj.memo_count(:value)).to eq(2) + end + + it "reset_memo with no args clears all versions" do + obj = klass.new + obj.value + obj.version = 2 + obj.value + obj.reset_memo(:value) + expect(obj.memo_count(:value)).to eq(0) + end + + it "reset_all_memos clears all versions" do + obj = klass.new + obj.value + obj.version = 2 + obj.value + obj.reset_all_memos + expect(obj.memo_count(:value)).to eq(0) + end + end + + # --------------------------------------------------------------------------- + # Composability + # --------------------------------------------------------------------------- + + describe "composability" do + it "composes with TTL — old token entries expire naturally" do + klass = Class.new do + prepend SafeMemoize + + attr_accessor :version + + def initialize = (@version = 1 + @calls = 0) + + def data + @calls += 1 + end + memoize :data, cache_bust: -> { @version }, ttl: 0.01 + + def call_count = @calls + end + + obj = klass.new + obj.data + sleep(0.02) + obj.data + expect(obj.call_count).to eq(2) + end + + it "composes with namespace:" do + klass = Class.new do + prepend SafeMemoize + + attr_accessor :version + + def initialize = (@version = 1 + @calls = 0) + + def result + @calls += 1 + end + memoize :result, cache_bust: -> { @version }, namespace: "ns" + + def call_count = @calls + end + + obj = klass.new + obj.result + obj.result + expect(obj.call_count).to eq(1) + obj.version = 2 + obj.result + expect(obj.call_count).to eq(2) + end + + it "composes with shared_cache: for cross-class busting" do + calls = 0 + + klass_a = Class.new do + prepend SafeMemoize + + attr_reader :token + + def initialize(token) = (@token = token) + + define_method(:fetch) { calls += 1 } + memoize :fetch, shared_cache: "bust_shared", cache_bust: -> { @token } + end + + klass_b = Class.new do + prepend SafeMemoize + + attr_reader :token + + def initialize(token) = (@token = token) + + define_method(:fetch) { calls += 1 } + memoize :fetch, shared_cache: "bust_shared", cache_bust: -> { @token } + end + + klass_a.new(1).fetch # miss + klass_b.new(1).fetch # hit — same token + expect(calls).to eq(1) + + klass_a.new(2).fetch # miss — new token + expect(calls).to eq(2) + ensure + SafeMemoize.reset_shared_caches! + end + end + + # --------------------------------------------------------------------------- + # Validation + # --------------------------------------------------------------------------- + + describe "validation" do + def bare_class + Class.new { + prepend SafeMemoize + + def x = 1 + } + end + + it "raises ArgumentError for a non-callable, non-Symbol value" do + expect { bare_class.memoize(:x, cache_bust: 42) } + .to raise_error(ArgumentError, /callable or Symbol/) + end + + it "raises ArgumentError when combined with key:" do + expect { bare_class.memoize(:x, cache_bust: -> { 1 }, key: -> { 1 }) } + .to raise_error(ArgumentError, /key:/) + end + end +end