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

### Added

- `SafeMemoize::Extension` — mixin for building SafeMemoize extensions. Extend it in any module to get a DSL for declaring custom `memoize` options and global lifecycle event handlers without monkey-patching SafeMemoize internals.
- `handles_option(name, &processor)` — declares a custom keyword argument that `memoize` will accept; the processor block is called at definition time with `(value, method_name, all_extension_options)` and must return a `Hash` of standard memoize options to inject (e.g. `{cache_bust: ...}`, `{ttl: 60}`, `{namespace: "v2"}`).
- `on_cache_event(*event_types, &handler)` — registers a global lifecycle handler that fires after every matching event (`:on_hit`, `:on_miss`, `:on_store`, `:on_expire`, `:on_evict`) across all memoized methods on all classes; handler receives `(klass, method_name, cache_key, record)`; runs on the main Ractor only.
- Duck-type compatible — any object responding to `handled_options`, `process_memoize_option`, and `dispatch_cache_event` works without `extend SafeMemoize::Extension`.
- `SafeMemoize.register_extension(name, extension)` — registers an extension under a symbolic name.
- `SafeMemoize.unregister_extension(name)` — removes an extension.
- `SafeMemoize.extensions` — returns a snapshot of the registry.
- `SafeMemoize.reset_extensions!` — clears the registry (test teardown).
- `SafeMemoize.extension_for_option(option_name)` — returns the registered extension that handles the named option, or `nil`.
- `memoize` now accepts `**extension_options` for any unknown keyword argument; each key is validated against registered extensions at call time and raises `ArgumentError` if no extension claims it, preserving the existing strict-options behaviour for typos.

- `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:`.
Expand Down
90 changes: 90 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
- [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)
- [Plugin / extension architecture — `SafeMemoize::Extension` DSL for adding custom `memoize` options and global lifecycle handlers without monkey-patching](#plugin--extension-architecture)

## Installation

Expand Down Expand Up @@ -732,6 +733,89 @@ Metrics are per-instance and reset independently from the cache itself — clear

[↑ Back to features](#features)

### Plugin / extension architecture

`SafeMemoize::Extension` lets third-party gems add custom `memoize` options and global lifecycle handlers without monkey-patching SafeMemoize internals.

```ruby
module MyExtension
extend SafeMemoize::Extension

# Declare a custom memoize option.
# The processor block runs at memoize definition time and returns
# a Hash of standard memoize options to inject.
handles_option :active_record_bust do |_value, _method_name, _options|
{ cache_bust: -> { send(:updated_at) } }
end

# Register a global lifecycle handler (fires for every memoized method).
on_cache_event :miss do |klass, method_name, _cache_key, _record|
Rails.logger.debug "cache miss: #{klass}##{method_name}"
end
end

SafeMemoize.register_extension(:active_record_bust, MyExtension)
```

Once registered, the custom option is accepted by `memoize`:

```ruby
class OrderDecorator
prepend SafeMemoize

def initialize(order) = (@order = order)

def summary = expensive_compute(@order)
memoize :summary, active_record_bust: true
# ↑ MyExtension injects cache_bust: -> { updated_at } automatically
end
```

#### `handles_option` processor return values

The processor block must return a Hash of **standard** `memoize` option keys to inject. Any standard option is supported:

```ruby
handles_option :short_lived do |ttl, _, _| { ttl: ttl } end
handles_option :versioned do |ns, _, _| { namespace: ns } end
handles_option :via_redis do |store, _, _| { store: store } end
handles_option :bust_on do |fn, _, _| { cache_bust: fn } end
```

#### `on_cache_event` handler signature

```ruby
on_cache_event :on_hit, :on_miss do |klass, method_name, cache_key, record|
# klass — the class whose instance triggered the event
# method_name — bare Symbol (namespace stripped)
# cache_key — full cache key Array
# record — { value:, expires_at:, cached_at: } or nil
end
```

Valid event types: `:on_hit`, `:on_miss`, `:on_store`, `:on_expire`, `:on_evict`.

#### Registry API

```ruby
SafeMemoize.register_extension(:name, MyExtension)
SafeMemoize.unregister_extension(:name)
SafeMemoize.extensions # { name: MyExtension, … }
SafeMemoize.reset_extensions! # clear registry (test teardown)
SafeMemoize.extension_for_option(:active_record_bust) # → MyExtension
```

#### Duck-type compatibility

An extension does not need to `extend SafeMemoize::Extension`. Any object responding to `handled_options`, `process_memoize_option`, and `dispatch_cache_event` is accepted.

#### Constraints

- Unknown `memoize` keywords raise `ArgumentError` unless a registered extension claims them — typos are still caught.
- `on_cache_event` handlers run on the main Ractor only; they are silently skipped from worker Ractors.

[↑ 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.
Expand Down Expand Up @@ -1424,6 +1508,11 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
| `SafeMemoize.drop_shared_cache(name)` | module method | Removes the named store from the registry |
| `SafeMemoize.shared_caches` | module method | Returns a snapshot of the registry |
| `SafeMemoize.reset_shared_caches!` | module method | Clears the entire registry (test teardown) |
| `SafeMemoize.register_extension(name, ext)` | module method | Registers a plugin extension |
| `SafeMemoize.unregister_extension(name)` | module method | Removes an extension |
| `SafeMemoize.extensions` | module method | Returns snapshot of extension registry |
| `SafeMemoize.reset_extensions!` | module method | Clears all extensions (test teardown) |
| `SafeMemoize.extension_for_option(name)` | module method | Returns the extension handling the named option |

### `memoize` DSL (class method, added by `prepend SafeMemoize`)

Expand All @@ -1442,6 +1531,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
| `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:` |
| *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |

### `memoize_all` options (class method)

Expand Down
4 changes: 0 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey

| Feature | Description | Status |
|---|---|---|
| Plugin / extension architecture | A formal `SafeMemoize::Extension` API so third-party gems can add new options, hooks, or store adapters without monkey-patching | Planned |
| 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 | Shipped |

---

Expand Down
69 changes: 69 additions & 0 deletions lib/safe_memoize.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require_relative "safe_memoize/version"
require_relative "safe_memoize/configuration"
require_relative "safe_memoize/extension"
require_relative "safe_memoize/stores/base"
require_relative "safe_memoize/stores/memory"
require_relative "safe_memoize/adapters/statsd"
Expand Down Expand Up @@ -59,6 +60,11 @@ class Error < StandardError; end
# @api private
SHARED_CACHE_MUTEX = Mutex.new

# @api private
EXTENSION_REGISTRY = {}
# @api private
EXTENSION_MUTEX = Mutex.new

include InstanceMethods

# @api private
Expand Down Expand Up @@ -171,4 +177,67 @@ def self.shared_caches
def self.reset_shared_caches!
SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.clear }
end

# Registers an extension under +name+.
#
# An extension is any Ruby object that optionally responds to the
# {Extension} interface: {Extension#handled_options}, {Extension#process_memoize_option},
# and {Extension#dispatch_cache_event}. Use {Extension} as a mixin to get a
# convenient DSL for defining these.
#
# @param name [Symbol, String] a unique identifier for this extension
# @param extension [Object] the extension object or module
# @return [Object] the registered extension
def self.register_extension(name, extension)
EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY[name.to_sym] = extension }
end

# Removes an extension from the registry.
#
# @param name [Symbol, String]
# @return [Object, nil] the removed extension, or +nil+ if not present
def self.unregister_extension(name)
EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.delete(name.to_sym) }
end

# Returns a snapshot of all registered extensions as a +Hash+.
#
# @return [Hash{Symbol => Object}]
def self.extensions
EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.dup }
end

# Removes all registered extensions.
#
# Useful in test suite +after+ hooks to prevent state leaking between examples.
#
# @return [void]
def self.reset_extensions!
EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.clear }
end

# Returns the first registered extension that declares it handles +option_name+,
# or +nil+ if none does.
#
# @param option_name [Symbol]
# @return [Object, nil]
def self.extension_for_option(option_name)
sym = option_name.to_sym
EXTENSION_MUTEX.synchronize do
EXTENSION_REGISTRY.values.find do |ext|
ext.respond_to?(:handled_options) && ext.handled_options.include?(sym)
end
end
end

# Dispatches a cache lifecycle event to all registered extensions that
# respond to +dispatch_cache_event+.
#
# @api private
def self.dispatch_extension_events(event_type, klass, method_name, cache_key, record)
exts = EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.values.dup }
exts.each do |ext|
ext.dispatch_cache_event(event_type, klass, method_name, cache_key, record) if ext.respond_to?(:dispatch_cache_event)
end
end
end
21 changes: 20 additions & 1 deletion lib/safe_memoize/class_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,32 @@ 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, cache_bust: 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, **extension_options)
method_name = method_name.to_sym

unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
raise ArgumentError, "cannot memoize :#{method_name} — no instance method with that name is defined on #{self}"
end

unless extension_options.empty?
extension_options.each_key do |opt|
raise ArgumentError, "unknown memoize option :#{opt} — no registered extension handles it" unless SafeMemoize.extension_for_option(opt)
end

injected = {}
extension_options.each do |opt, val|
result = SafeMemoize.extension_for_option(opt).process_memoize_option(opt, val, method_name, extension_options)
injected.merge!(result)
end

ttl = injected[:ttl] if injected.key?(:ttl)
max_size = injected[:max_size] if injected.key?(:max_size)
namespace = injected[:namespace] if injected.key?(:namespace)
store = injected[:store] if injected.key?(:store)
shared_cache = injected[:shared_cache] if injected.key?(:shared_cache)
cache_bust = injected[:cache_bust] if injected.key?(:cache_bust)
end

visibility = memoized_method_visibility(method_name)

config = SafeMemoize.configuration
Expand Down
88 changes: 88 additions & 0 deletions lib/safe_memoize/extension.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

module SafeMemoize
# Mixin for defining SafeMemoize extensions.
#
# Extend this module in any Ruby module or class that you want to register
# as a SafeMemoize extension. It provides a DSL for declaring custom
# +memoize+ options and global cache lifecycle event handlers.
#
# @example Defining an extension
# module MyExtension
# extend SafeMemoize::Extension
#
# handles_option :active_record_bust do |value, method_name, _options|
# { cache_bust: -> { send(:updated_at) } }
# end
#
# on_cache_event :miss do |klass, method_name, _cache_key, _record|
# Rails.logger.debug "cache miss: #{klass}##{method_name}"
# end
# end
#
# SafeMemoize.register_extension(:active_record_bust, MyExtension)
module Extension
# Declares a custom +memoize+ option handled by this extension.
#
# The block is called at +memoize+ definition time whenever +option_name+
# appears in the +memoize+ keyword arguments. It receives the option value,
# the method name being memoized, and the full hash of other extension options
# passed to that +memoize+ call. It must return a +Hash+ of standard
# {ClassMethods#memoize} options to inject (e.g. +{ cache_bust: ... }+), or
# +nil+/empty hash for no injection.
#
# @param option_name [Symbol]
# @yieldparam value [Object] the option value supplied by the caller
# @yieldparam method_name [Symbol] the method being memoized
# @yieldparam all_options [Hash] other extension options in the same +memoize+ call
# @yieldreturn [Hash, nil] standard memoize options to inject
# @return [void]
def handles_option(option_name, &processor)
@__handled_options__ ||= {}
@__handled_options__[option_name.to_sym] = processor
end

# Registers a global cache lifecycle event handler.
#
# The block fires after every matching cache event across *all* memoized
# methods on all classes. Multiple event types can be listed in a single
# call. Valid types are +:on_hit+, +:on_miss+, +:on_store+, +:on_expire+,
# and +:on_evict+.
#
# Handlers execute on the main Ractor only; they are silently skipped from
# worker Ractors.
#
# @param event_types [Array<Symbol>] one or more of +:on_hit+, +:on_miss+,
# +:on_store+, +:on_expire+, +:on_evict+
# @yieldparam klass [Class] the class whose instance triggered the event
# @yieldparam method_name [Symbol] bare method name (namespace stripped)
# @yieldparam cache_key [Array] the full cache key
# @yieldparam record [Hash, nil] the cache record (+value+, +expires_at+, +cached_at+)
# @return [void]
def on_cache_event(*event_types, &handler)
@__event_handlers__ ||= {}
event_types.each { |type| (@__event_handlers__[type.to_sym] ||= []) << handler }
end

# @api private
def handled_options
@__handled_options__&.keys || []
end

# @api private
def process_memoize_option(option_name, value, method_name, all_options)
processor = @__handled_options__&.[](option_name.to_sym)
result = processor&.call(value, method_name, all_options)
result.is_a?(Hash) ? result : {}
end

# @api private
def dispatch_cache_event(event_type, klass, method_name, cache_key, record)
return unless @__event_handlers__

(@__event_handlers__[event_type] || []).each do |handler|
handler.call(klass, method_name, cache_key, record)
end
end
end
end
2 changes: 2 additions & 0 deletions lib/safe_memoize/hooks_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ def call_memo_hooks(hook_type, cache_key, record)
if (client = SafeMemoize.configuration.statsd_client)
Adapters::StatsD.dispatch(client, hook_type, cache_key, self.class.name)
end

SafeMemoize.dispatch_extension_events(hook_type, self.class, safe_memo_bare_method_name(cache_key[0]), cache_key, record)
end

def safe_memo_notify(hook_type, cache_key)
Expand Down
Loading
Loading