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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min

## [Unreleased]

## [1.5.0] - 2026-06-02

### Added

- `group:` option on `memoize` — assigns a method to a named invalidation group (`memoize :find, group: :database`). Groups are stored on the class and survive re-memoization; a method can belong to at most one group at a time (re-memoizing with a different group moves it). Accepts any non-empty Symbol or String. Can be set as a class default via `safe_memoize_options group: :my_group`.
- `reset_memo_group(group_name)` instance method — clears all per-instance cached entries for every method in the named group in a single call; each evicted entry fires the `:on_evict` hook. A no-op for unknown groups.
- `reset_shared_memo_group(group_name)` class method — the shared-cache equivalent of `reset_memo_group`; clears all shared-cache entries for every method in the group that was memoized with `shared: true`.
- `memo_group_methods(group_name)` instance method — returns the array of method names belonging to the given group on the instance's class (empty array for unknown groups).
- `memo_groups` instance method — returns all group names registered on the instance's class.
- `safe_memo_group_methods(group_name)` class method — class-level equivalent of `memo_group_methods`.
- `safe_memo_groups` class method — class-level equivalent of `memo_groups`.

## [1.4.0] - 2026-06-02

### Added
Expand Down
93 changes: 92 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
- [Plugin / extension architecture — `SafeMemoize::Extension` DSL for adding custom `memoize` options and global lifecycle handlers without monkey-patching](#plugin--extension-architecture)
- [Per-class default options via `safe_memoize_options` — set TTL, max size, copy-on-read, and other defaults for every `memoize` call on the class without repeating them](#per-class-default-options-safe_memoize_options)
- [Copy-on-read via `copy_on_read: true` — returns a `dup`/`deep_dup` on every cache read to protect shared cached state from caller mutation](#copy-on-read)
- [Cache invalidation groups via `group:` — tag related methods with a group name and bust them all with a single `reset_memo_group` call](#cache-invalidation-groups)

## Installation

Expand Down Expand Up @@ -194,6 +195,79 @@ obj.reset_all_memos # Clears all memoized values

[↑ Back to features](#features)

### Cache invalidation groups

Tag related methods with `group:` and bust them all at once with a single `reset_memo_group` call:

```ruby
class RepoService
prepend SafeMemoize

def find_user(id) = db.query("SELECT * FROM users WHERE id=?", id)
def find_post(id) = db.query("SELECT * FROM posts WHERE id=?", id)
def site_config = db.query("SELECT * FROM config LIMIT 1")

memoize :find_user, group: :database
memoize :find_post, group: :database
memoize :site_config # no group — unaffected by group reset
end

svc = RepoService.new
svc.find_user(1)
svc.find_post(42)
svc.site_config

svc.reset_memo_group(:database) # invalidates find_user and find_post only
svc.memoized?(:site_config) # => true — unaffected
```

For `shared: true` methods, use the class method:

```ruby
class CatalogService
prepend SafeMemoize

def products = fetch_all_products
def categories = fetch_all_categories

memoize :products, shared: true, group: :catalog
memoize :categories, shared: true, group: :catalog
end

CatalogService.reset_shared_memo_group(:catalog) # clears shared cache for both methods
```

#### Introspection

```ruby
svc.memo_groups # => [:database] — all groups on the class
svc.memo_group_methods(:database) # => [:find_user, :find_post]
CatalogService.safe_memo_groups # => [:catalog]
CatalogService.safe_memo_group_methods(:catalog) # => [:products, :categories]
```

#### Class-wide group default

Use `safe_memoize_options` to assign all subsequently memoized methods to the same group:

```ruby
class ApiClient
prepend SafeMemoize
safe_memoize_options group: :api

def users = http.get("/users")
def orders = http.get("/orders")

memoize :users # group: :api
memoize :orders # group: :api
memoize :health, group: nil # override — no group
end
```

A method belongs to at most one group at a time; re-memoizing with a different `group:` moves it.

[↑ Back to features](#features)

### Lifecycle hooks

Register callbacks that fire when cached entries are evicted or expire.
Expand Down Expand Up @@ -1611,6 +1685,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
| `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:` |
| `copy_on_read:` | `Boolean` | `false` | Return a `dup`/`deep_dup` of the cached value on every read; protects shared state from caller mutation; nil and frozen values pass through; incompatible with `ractor_safe:` |
| `group:` | `Symbol \| String \| nil` | `nil` | Assigns the method to a named invalidation group; call `reset_memo_group` / `reset_shared_memo_group` to bust all methods in the group at once; a method belongs to at most one group |
| *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |

### `memoize_all` options (class method)
Expand All @@ -1628,7 +1703,7 @@ All `memoize` option keys above, plus:

| Option key | Type | Default | Notes |
|---|---|---|---|
| any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |
| any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`, `group:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |

### Instance methods (public)

Expand All @@ -1650,10 +1725,18 @@ All `memoize` option keys above, plus:
| Method | Returns |
|---|---|
| `reset_memo(method_name, *args, **kwargs)` | `nil` |
| `reset_memo_group(group_name)` | `nil` |
| `reset_all_memos` | `nil` |
| `memo_touch(method_name, *args, ttl: nil, **kwargs)` | `Boolean` |
| `memo_refresh(method_name, *args, **kwargs)` | cached value |

**Group introspection**

| Method | Returns |
|---|---|
| `memo_groups` | `Array<Symbol>` — all group names on the class |
| `memo_group_methods(group_name)` | `Array<Symbol>` — methods in the group |

**Warm-up and persistence**

| Method | Returns |
Expand Down Expand Up @@ -1705,11 +1788,19 @@ All `memoize` option keys above, plus:
|---|---|
| `reset_shared_memo(method_name, *args, **kwargs)` | `nil` |
| `reset_all_shared_memos` | `nil` |
| `reset_shared_memo_group(group_name)` | `nil` |
| `shared_memoized?(method_name, *args, **kwargs)` | `Boolean` |
| `shared_memo_count(method_name = nil)` | `Integer` |
| `shared_memo_age(method_name, *args, **kwargs)` | `Numeric \| nil` |
| `shared_memo_stale?(method_name, *args, **kwargs)` | `Boolean` |

### Group class methods (available on any class that uses `group:`)

| Method | Returns |
|---|---|
| `safe_memo_groups` | `Array<Symbol>` — all group names on the class |
| `safe_memo_group_methods(group_name)` | `Array<Symbol>` — methods belonging to the group |

**Ractor-safe shared cache (added when any method uses `ractor_safe: true`)**

| Method | Returns |
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey

| Feature | Description | Status |
|---|---|---|
| Memoization groups | `memoize :find, group: :database` then `reset_memo_group(:database)` to invalidate all methods tagged with the same group at once; groups can span multiple methods on the same class | Planned |
| Memoization groups | `memoize :find, group: :database` then `reset_memo_group(:database)` to invalidate all methods tagged with the same group at once; groups can span multiple methods on the same class | Shipped |

---

Expand Down
58 changes: 57 additions & 1 deletion lib/safe_memoize/class_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ module ClassMethods
# itself. Prevents callers from mutating shared cached state. Frozen and +nil+
# values are returned as-is. Incompatible with +ractor_safe:+ (ractor values are
# always frozen; use that guarantee instead).
# @param group [Symbol, String, nil] assigns the method to a named invalidation
# group. Call {PublicMethods#reset_memo_group} on an instance (or
# {.reset_shared_memo_group} for shared-mode methods) to bust every method in the
# group at once. A method may belong to at most one group; re-memoizing with a
# different group moves it. Must be a non-empty Symbol or String.
# @return [void]
# @raise [ArgumentError] if the method does not exist, or option values are invalid
#
Expand All @@ -84,7 +89,7 @@ module ClassMethods
# @example With a custom store
# STORE = SafeMemoize::Stores::Memory.new
# memoize :fetch, store: STORE, ttl: 300
def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UNSET, unless: UNSET, shared: UNSET, key: UNSET, store: UNSET, fiber_local: UNSET, ractor_safe: UNSET, namespace: UNSET, shared_cache: UNSET, cache_bust: UNSET, copy_on_read: UNSET, **extension_options)
def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UNSET, unless: UNSET, shared: UNSET, key: UNSET, store: UNSET, fiber_local: UNSET, ractor_safe: UNSET, namespace: UNSET, shared_cache: UNSET, cache_bust: UNSET, copy_on_read: UNSET, group: UNSET, **extension_options)
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 @@ -126,6 +131,7 @@ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UN
copy_on_read = cls_defaults[:copy_on_read] if copy_on_read.equal?(UNSET) && cls_defaults.key?(:copy_on_read)
namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace)
store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
group = cls_defaults[:group] if group.equal?(UNSET) && cls_defaults.key?(:group)
end

# Normalize remaining UNSET to original per-call defaults
Expand All @@ -141,6 +147,7 @@ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UN
shared_cache = nil if shared_cache.equal?(UNSET)
cache_bust = nil if cache_bust.equal?(UNSET)
copy_on_read = false if copy_on_read.equal?(UNSET)
group = nil if group.equal?(UNSET)
cond_if = nil if cond_if.equal?(UNSET)
cond_unless = nil if cond_unless.equal?(UNSET)

Expand Down Expand Up @@ -213,6 +220,15 @@ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UN
__safe_memo_method_namespaces__[method_name] = namespace
end

if group
unless group.is_a?(Symbol) || group.is_a?(String)
raise ArgumentError, "group: must be a Symbol or String (got #{group.class})"
end
group = group.to_sym
raise ArgumentError, "group: must not be empty" if group.empty?
__safe_memo_register_group__(method_name, group)
end

if shared_cache
raise ArgumentError, "shared_cache: must be a String (got #{shared_cache.class})" unless shared_cache.is_a?(String)
raise ArgumentError, "shared_cache: must not be empty" if shared_cache.empty?
Expand Down Expand Up @@ -763,6 +779,35 @@ def shared_memo_stale?(method_name, *args, **kwargs)
end
end

# Clears all shared-cache entries for every method in the given group.
#
# Only affects methods memoized with +shared: true+. For per-instance cache
# invalidation use {PublicMethods#reset_memo_group} on the instance.
#
# @param group_name [Symbol, String]
# @return [void]
def reset_shared_memo_group(group_name)
group_name = group_name.to_sym
(__safe_memo_groups__[group_name] || []).each { |m| reset_shared_memo(m) }
end

# Returns the method names belonging to the given invalidation group, or an
# empty array when the group is unknown.
#
# @param group_name [Symbol, String]
# @return [Array<Symbol>]
def safe_memo_group_methods(group_name)
group_name = group_name.to_sym
(__safe_memo_groups__[group_name] || []).dup
end

# Returns all group names registered on this class.
#
# @return [Array<Symbol>]
def safe_memo_groups
__safe_memo_groups__.keys
end

private

def __safe_memo_shared_cache__
Expand Down Expand Up @@ -793,6 +838,17 @@ def __safe_memoize_defaults__
@__safe_memoize_defaults__
end

def __safe_memo_groups__
@__safe_memo_groups__ ||= {}
end

def __safe_memo_register_group__(method_name, group)
groups = __safe_memo_groups__
# Remove method from any prior group it belonged to
groups.each_value { |methods| methods.delete(method_name) }
(groups[group] ||= []) << method_name
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
31 changes: 31 additions & 0 deletions lib/safe_memoize/public_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,37 @@ def reset_memo(method_name, *args, **kwargs)
end
end

# Clears all per-instance cached entries for every method belonging to the
# given invalidation group (declared via +memoize :method, group: :name+).
#
# A no-op when the group is unknown or has no members. Each evicted entry
# fires the +:on_evict+ hook. For shared-mode methods use the class-level
# {ClassMethods.reset_shared_memo_group} instead.
#
# @param group_name [Symbol, String]
# @return [void]
def reset_memo_group(group_name)
group_name = group_name.to_sym
(self.class.send(:__safe_memo_groups__)[group_name] || []).each { |m| reset_memo(m) }
end

# Returns the method names belonging to the given invalidation group on
# this instance's class, or an empty array when the group is unknown.
#
# @param group_name [Symbol, String]
# @return [Array<Symbol>]
def memo_group_methods(group_name)
group_name = group_name.to_sym
(self.class.send(:__safe_memo_groups__)[group_name] || []).dup
end

# Returns all invalidation group names registered on this instance's class.
#
# @return [Array<Symbol>]
def memo_groups
self.class.send(:__safe_memo_groups__).keys
end

# Clears all cached entries for every method on this instance.
# Each evicted entry fires the +:on_evict+ hook.
#
Expand Down
11 changes: 10 additions & 1 deletion sig/safe_memoize.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module SafeMemoize
@__safe_memo_shared_cache__: Hash[memo_key, memo_record]?
@__safe_memo_shared_mutex__: Mutex?
@__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
@__safe_memo_groups__: Hash[Symbol, Array[Symbol]]?

UNSET: untyped
SHARED_CACHE_REGISTRY: Hash[String, Stores::Base]
Expand Down Expand Up @@ -65,7 +66,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?, ?cache_bust: (^() -> untyped) | Symbol | nil, ?copy_on_read: bool, **untyped extension_options) -> 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, ?copy_on_read: bool, ?group: Symbol | String | nil, **untyped extension_options) -> void
def safe_memoize_store: () -> Stores::Base?
def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
def safe_memoize_namespace: () -> String?
Expand All @@ -78,6 +79,9 @@ module SafeMemoize
def shared_memo_count: (?Symbol | String method_name) -> Integer
def shared_memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float?
def shared_memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
def reset_shared_memo_group: (Symbol | String group_name) -> void
def safe_memo_group_methods: (Symbol | String group_name) -> Array[Symbol]
def safe_memo_groups: () -> Array[Symbol]

private

Expand All @@ -88,6 +92,8 @@ module SafeMemoize
def __safe_memo_method_namespaces__: () -> Hash[Symbol, String]
def __safe_memo_class_cache_bust_generators__: () -> Hash[Symbol, Proc | Symbol]
def __safe_memoize_defaults__: () -> Hash[Symbol, untyped]?
def __safe_memo_groups__: () -> Hash[Symbol, Array[Symbol]]
def __safe_memo_register_group__: (Symbol method_name, Symbol group) -> void
def memoized_method_visibility: (Symbol method_name) -> Symbol
end

Expand All @@ -114,6 +120,9 @@ module SafeMemoize
def memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float?
def memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
def reset_memo_group: (Symbol | String group_name) -> void
def memo_group_methods: (Symbol | String group_name) -> Array[Symbol]
def memo_groups: () -> Array[Symbol]
def reset_all_memos: () -> void
def memo_inspect: (Symbol | String method_name, *untyped args, **untyped kwargs) -> { cached: bool, value: untyped, hits: Integer, misses: Integer, ttl_remaining: Float?, age: Float?, custom_key: untyped, lru_position: Integer? }?
end
Expand Down
Loading
Loading