Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
22 changes: 21 additions & 1 deletion lib/safe_memoize/class_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion lib/safe_memoize/custom_key_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion sig/safe_memoize.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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

Expand Down
Loading
Loading