SafeMemoize
Thread-safe memoization for Ruby that correctly handles nil and false values.
SafeMemoize is a production-ready, zero-dependency memoization library for Ruby. It wraps methods with a prepend-based cache that handles everything the standard ||= idiom gets wrong: nil and false return values are cached correctly, per-argument result maps eliminate redundant computation for parameterized methods, and a per-instance Mutex with double-check locking makes the whole thing safe under concurrent load.
Beyond the basics, SafeMemoize ships with TTL expiration (including sliding window refresh via ttl_refresh:), LRU cache size capping, conditional caching via if:/unless: predicates, lifecycle hooks for cache hits, evictions, and expirations, per-instance metrics (hit rate, miss rate, average computation time), targeted and bulk cache invalidation, custom cache key generators, and rich introspection helpers (memoized?, memo_count, memo_keys, memo_values, memo_ttl_remaining). It preserves method visibility (public, protected, and private) and requires no runtime dependencies.
The Problem
Ruby's common memoization pattern breaks with falsy values:
def user
@user ||= find_user # Re-runs find_user every time it returns nil!
end
SafeMemoize uses Hash#key? to distinguish "not yet cached" from "cached nil/false", so your methods are only computed once regardless of return value.
How It Works
SafeMemoize uses Ruby's prepend mechanism. When you call memoize :method_name, it creates an anonymous module with a wrapper method and prepends it onto your class. The wrapper calls super to invoke the original method and stores the result in a per-instance hash. Thread safety is achieved with a per-instance Mutex using double-check locking.
Features
- Correctly memoizes
nilandfalsereturn values - Caches per unique arguments (positional and keyword)
- Thread-safe via double-check locking
- Simple
prepend+memoizeAPI - Preserves public, protected, and private method visibility
- Supports targeted cache invalidation by argument combination
- Includes a
memoized?helper for cache inspection - Includes a
memo_counthelper for cache size stats - Includes a
memo_keyshelper for inspecting cached signatures - Includes a
memo_valueshelper for inspecting cached signatures and values - Optional TTL expiration support for cached entries
- Sliding window TTL via
ttl_refresh: true - Optional LRU cache size limit per method via
max_size: - Conditional caching via
if:andunless:predicates - Lifecycle hooks for hit, miss, eviction, and expiration events
- Per-instance cache metrics (hit rate, miss rate, computation time)
- Cache warm-up, export, and restore (
warm_memo,dump_memo,load_memo) - Class-level shared cache via
shared: truewith optional LRU - Bulk memoization via
memoize_all(public, protected, and private) - Custom cache key generation per method
- TTL introspection via
memo_ttl_remaining - Deep single-entry inspection via
memo_inspect ArgumentErrorat definition time when memoizing an undefined method- Hook error isolation — hook exceptions never propagate to callers
- Deprecation infrastructure for gem authors
- Optional
ActiveSupport::Notificationsintegration for Rails observability - Optional StatsD adapter for metrics pipelines
- Optional OpenTelemetry adapter for distributed tracing
- Rails request-scope helpers for controllers and service objects
- Batch cache warm-up via
memo_preload on_memo_storehook fires on every cache write- Global default TTL and max size via
SafeMemoize.configure memo_touchresets the expiry clock without recomputingmemo_refreshforce-recomputes and re-caches in one callmemo_ageandmemo_stale?for TTL introspection- Class-level
key:option for shared cache key generation shared_memo_ageandshared_memo_stale?for shared cache TTL inspection- Pluggable external cache stores — Redis, Rails.cache, or any custom adapter
- Global default store via
Configuration#default_store SafeMemoize::Adapters::ConcurrentRuby— optionalconcurrent-rubystore with parallel-read locking- Class-level
.safe_memoize_store=— set a per-class default store without touching global config - Fiber-local memoization via
fiber_local: true— isolated per-fiber cache, no mutex, works with Async/Falcon - Ractor-safe shared cache via
ractor_safe: true— supervisor Ractor replaces the Mutex; worker Ractors can call the memoized method directly
Installation
Add to your Gemfile:
gem "safe_memoize"
Then run:
bundle install
Or install directly:
gem install safe_memoize
Usage
Basic memoization
class UserService
prepend SafeMemoize
def current_user
# This expensive lookup runs only once
User.find_by(session_id: session_id)
end
memoize :current_user
end
Calling memoize on a method name that does not exist raises ArgumentError immediately at class definition time rather than at the first runtime call.
With arguments
Results are cached per unique argument combination:
class Calculator
prepend SafeMemoize
def compute(x, y)
sleep(2)
x + y
end
memoize :compute
end
calc = Calculator.new
calc.compute(1, 2) # computes and caches
calc.compute(1, 2) # returns cached result
calc.compute(3, 4) # computes and caches (different args)
Argument arrays, hashes, and strings are deep-frozen into an independent copy when the cache key is built, so mutating arguments after a call cannot corrupt or miss a cached entry.
Nil and false safety
class Config
prepend SafeMemoize
def enabled?
# Only called once, even though it returns false
ENV["FEATURE_FLAG"] == "true"
end
memoize :enabled?
end
Works with private methods
class TokenProvider
prepend SafeMemoize
def bearer_token
token
end
private
def token
fetch_token_from_service
end
memoize :token
end
Cache reset
obj = MyService.new
obj.reset_memo(:current_user) # Clears all cached entries for one method
obj.reset_memo(:find_user, 42) # Clears only the cached call for find_user(42)
obj.reset_memo(:search, "ruby", page: 2) # Clears one positional/keyword combination
obj.reset_all_memos # Clears all memoized values
Lifecycle hooks
Register callbacks that fire when cached entries are evicted or expire.
on_memo_evict fires when an entry is removed via reset_memo, reset_all_memos, or LRU eviction:
obj.on_memo_evict do |cache_key, record|
Rails.logger.info("Evicted #{cache_key[0]}(#{cache_key[1].join(", ")}), was: #{record[:value].inspect}")
end
on_memo_miss fires on every cache miss (i.e. the first call or after invalidation):
obj.on_memo_miss do |cache_key, record|
Rails.logger.debug("Cache miss: #{cache_key[0]}(#{cache_key[1].join(", ")})")
end
on_memo_hit fires on every cache hit:
obj.on_memo_hit do |cache_key, record|
StatsD.increment("cache.hit", tags: ["method:#{cache_key[0]}"])
end
on_memo_expire fires when a TTL entry is detected as expired (on the next call or during inspection):
obj.on_memo_expire do |cache_key, record|
Rails.logger.debug("TTL expired: #{cache_key[0]}")
end
on_memo_store fires whenever a value is written to the cache — on a miss, via warm_memo, or via load_memo:
obj.on_memo_store do |cache_key, record|
Rails.logger.debug("Stored #{cache_key[0]}: #{record[:value].inspect}")
end
Multiple hooks of the same type can be registered and all will fire. Remove them with clear_memo_hooks:
obj.clear_memo_hooks(:on_miss) # Clears miss hooks only
obj.clear_memo_hooks(:on_hit) # Clears hit hooks only
obj.clear_memo_hooks(:on_evict) # Clears evict hooks only
obj.clear_memo_hooks(:on_expire) # Clears expire hooks only
obj.clear_memo_hooks # Clears all hooks
Hooks are per-instance and do not affect other objects of the same class.
Hook error isolation
Exceptions raised inside a hook never propagate to the caller. By default a warning is emitted to stderr:
[SafeMemoize] Hook error in on_miss: undefined method `log' for nil
Configure a custom handler via SafeMemoize.configure:
SafeMemoize.configure do |c|
c.on_hook_error = ->(error, hook_type, cache_key) {
MyErrorTracker.capture(error, context: { hook: hook_type, key: cache_key })
}
end
Set c.on_hook_error = :raise to re-raise exceptions instead of swallowing them.
TTL expiration
class QuoteService
prepend SafeMemoize
def current_quote
fetch_quote_from_api
end
memoize :current_quote, ttl: 60
end
With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
Use memo_touch to reset the expiry clock on a cached entry without recomputing its value:
obj.memo_touch(:current_quote) # Resets TTL to the original duration
obj.memo_touch(:current_quote, ttl: 120) # Resets TTL to a new duration
# => true on success, false if the entry is not cached or already expired
Use memo_refresh to force-recompute a cached entry immediately and store the new value:
obj.memo_refresh(:current_quote) # Recomputes and re-caches
obj.memo_refresh(:find, 42) # Recomputes for one argument combination
Sliding window TTL
Add ttl_refresh: true to reset the expiry clock on every cache hit, so the entry only expires after a full TTL of inactivity:
class SessionService
prepend SafeMemoize
def user_data(user_id)
fetch_from_db(user_id)
end
memoize :user_data, ttl: 300, ttl_refresh: true
end
Without ttl_refresh:, the entry expires 300 seconds after it was first cached. With it, the clock resets on every read — the entry is evicted only if the method goes 300 seconds without being called. ttl_refresh: true requires ttl: to be set and works with both per-instance and shared: true memoization.
LRU cache size limit
Pass max_size: to cap how many entries a method can hold. When the limit is reached the least-recently-used entry is evicted to make room:
class ProductService
prepend SafeMemoize
def find(id)
Product.find(id)
end
memoize :find, max_size: 100
end
Cache hits count as recent access, so a frequently-read entry will never be the one evicted:
svc = ProductService.new
svc.find(1) # miss — cached
svc.find(2) # miss — cached
svc.find(1) # hit — promotes 1 to most-recently-used; 2 is now LRU
svc.find(3) # miss — evicts 2 (LRU), caches 3
max_size: combines with ttl: — LRU eviction applies within the TTL window, and entries also expire normally when the TTL elapses:
memoize :find, max_size: 50, ttl: 300
The on_evict hook fires for LRU-evicted entries the same way it does for manual reset_memo calls.
Conditional caching
Use if: to cache a result only when the predicate returns truthy, or unless: to skip caching when it returns truthy. Calls that don't satisfy the condition recompute every time until they do.
class UserService
prepend SafeMemoize
# Don't cache nil — retries on every call until a user is found
def find(id)
User.find_by(id: id)
end
memoize :find, if: ->(result) { !result.nil? }
end
class DataService
prepend SafeMemoize
# Don't cache error responses
def fetch(key)
api_client.get(key)
end
memoize :fetch, unless: ->(result) { result.is_a?(ErrorResponse) }
end
Both options accept any callable and compose with ttl: and max_size::
memoize :find, if: ->(result) { !result.nil? }, ttl: 60, max_size: 500
Cache warm-up and persistence
Batch warm-up via memo_preload
Use memo_preload to warm multiple argument combinations in one call. It calls the memoized method for each argument set, caches all results, and returns them in input order:
obj.memo_preload(:find, [1], [2], [3])
# => [<User id=1>, <User id=2>, <User id=3>]
Each element is a separate argument list passed to the method, so keyword arguments work too:
obj.memo_preload(:search, ["ruby"], ["rails"], ["rspec"])
Warming individual entries
Use warm_memo to pre-populate a cache entry without calling the method. The block provides the value:
obj.warm_memo(:current_user) { User.find(session[:user_id]) }
obj.warm_memo(:find, 42) { cached_user }
obj.warm_memo(:search, "ruby", page: 2) { cached_results }
Pass ttl: to give the warmed entry an expiry:
obj.warm_memo(:current_quote, ttl: 60) { cached_quote }
Useful for seeding the cache from a persistent store on startup, or overriding a cached value in tests.
Exporting and restoring the cache
dump_memo exports all live cached entries as a plain hash keyed by [method, args, kwargs]:
snapshot = obj.dump_memo # All methods
snapshot = obj.dump_memo(:find) # One method only
# => { [:find, [1], {}] => <User>, [:find, [2], {}] => <User>, ... }
load_memo restores entries from a snapshot — merging into the existing cache without evicting unrelated entries:
obj.load_memo(snapshot)
Together they enable cross-request or cross-process cache persistence:
# On shutdown — save to Redis
redis.set("cache:#{user_id}", Marshal.dump(obj.dump_memo))
# On boot — restore from Redis
raw = redis.get("cache:#{user_id}")
obj.load_memo(Marshal.load(raw)) if raw
Loaded entries have no TTL — they persist until explicitly reset. Expired entries are excluded from dump_memo output, so snapshots never contain stale data.
Shared cache
Pass shared: true to store results on the class instead of per-instance. All instances share one cache, so the method is computed only once regardless of how many objects exist.
class ConfigService
prepend SafeMemoize
def database_url
ENV.fetch("DATABASE_URL")
end
def feature_flags
fetch_flags_from_api
end
memoize :database_url, shared: true
memoize :feature_flags, shared: true, ttl: 300
end
ConfigService.new.database_url # computes
ConfigService.new.database_url # returns cached — no recomputation
Class-level invalidation and inspection:
ConfigService.reset_shared_memo(:feature_flags) # Clears all entries for one method
ConfigService.reset_shared_memo(:find, user_id) # Clears one argument combination
ConfigService.reset_all_shared_memos # Clears all shared cached entries
ConfigService.shared_memoized?(:database_url) # => true
ConfigService.shared_memoized?(:find, user_id) # Checks one argument combination
ConfigService.shared_memo_count # Total shared cached entries
ConfigService.shared_memo_count(:find) # Entries for one method
ConfigService.shared_memo_age(:feature_flags) # => 42.1 (seconds since cached)
ConfigService.shared_memo_stale?(:feature_flags) # => false (TTL not yet elapsed)
shared: true supports ttl:, ttl_refresh:, if:, unless:, and max_size: options.
Pass max_size: to cap how many entries are kept across all instances. Eviction is LRU, tracked at the class level:
memoize :find, shared: true, max_size: 500
Hooks (on_memo_hit, on_memo_miss, on_memo_expire, on_memo_evict) fire on the calling instance as usual.
Fiber-local memoization
Pass fiber_local: true to store results in Fiber[:__safe_memoize__] rather than instance variables. Each fiber gets its own isolated cache that is automatically discarded when the fiber terminates — no explicit cleanup required.
This is the right choice for Fiber-based concurrency frameworks like Async, Falcon, and Rails async controllers, where multiple fibers share the same object instance and must not see each other's cached values.
class ApiClient
prepend SafeMemoize
def fetch(path)
http_get(path)
end
memoize :fetch, fiber_local: true
end
client = ApiClient.new
Fiber.new { client.fetch("/a") }.resume # computes in this fiber
Fiber.new { client.fetch("/a") }.resume # computes again — isolated cache
fiber_local: true works with all standard options: ttl:, ttl_refresh:, max_size:, if:, unless:, and key:. It is incompatible with shared: and store: (both raise ArgumentError).
No Mutex is acquired because fibers within a single thread are cooperative — only one fiber executes at a time.
Fiber isolation guarantee: Ruby's Fiber.new inherits the parent fiber's local storage by default. SafeMemoize detects inherited stores via an ownership sentinel and replaces them with a fresh, isolated store on first write, so child fibers never see the parent's cached entries.
Instance-level inspection and reset for fiber-local entries use dedicated methods:
obj.fiber_local_memoized?(:fetch, "/a") # true / false for the current fiber
obj.reset_fiber_memo(:fetch) # clear all entries for :fetch in current fiber
obj.reset_fiber_memo(:fetch, "/a") # clear one specific entry
obj.reset_all_fiber_memos # clear all fiber-local entries for this instance
Lifecycle hooks and cache metrics work the same as for regular memoization. The existing memoized?, reset_memo, and memo_count methods operate on the instance-variable cache; use the fiber_local_* / reset_fiber_* API for fiber-local entries.
Bulk memoization
Use memoize_all to memoize every public method defined on the class in one call:
class ConfigService
prepend SafeMemoize
def database_url
ENV.fetch("DATABASE_URL")
end
def redis_url
ENV.fetch("REDIS_URL")
end
def feature_flags
fetch_flags_from_api
end
memoize_all
end
All options accepted by memoize can be passed as shared options:
memoize_all ttl: 60
memoize_all max_size: 100
memoize_all if: ->(result) { !result.nil? }
Use except: to skip specific methods:
memoize_all except: [:version, :name]
Use only: to explicitly list the methods to memoize and skip all others:
memoize_all only: [:database_url, :redis_url]
only: and except: are mutually exclusive — passing both raises ArgumentError.
By default only public methods defined directly on the class are memoized. Use include_protected: or include_private: to opt those visibilities in:
memoize_all include_protected: true
memoize_all include_private: true
memoize_all include_protected: true, include_private: true
Inherited methods are never affected regardless of visibility.
Custom cache keys
By default the cache key is derived from the method name and all arguments. Use the key: option on memoize to set a class-level key generator that applies to every instance:
class ReportService
prepend SafeMemoize
def generate(user_id, )
build_report(user_id, )
end
memoize :generate, key: ->(user_id, ) { user_id }
end
# All instances share the same key logic — calls with the same user_id share one cache entry
svc = ReportService.new
svc.generate(42, {format: :pdf}) # computes and caches under key 42
svc.generate(42, {format: :csv}) # cache hit — same key
For per-instance key overrides, use memoize_with_custom_key on an instance (takes priority over the class-level key: option):
svc = ReportService.new
# Cache only by user_id — ignore the options hash entirely
svc.memoize_with_custom_key(:generate) { |user_id, | user_id }
svc.generate(42, {format: :pdf}) # computes and caches
svc.generate(42, {format: :csv}) # cache hit — same user_id, options ignored
The block can return any comparable value — a scalar, array, or hash:
svc.memoize_with_custom_key(:generate) do |user_id, |
{user: user_id, locale: [:locale]}
end
Custom key generators are per-instance and can be cleared at any time:
svc.clear_custom_keys(:generate) # Remove generator for one method
svc.clear_custom_keys # Remove all custom key generators
Cache inspection
obj = MyService.new
obj.memoized?(:current_user) # => false
obj.current_user
obj.memoized?(:current_user) # => true
obj.memoized?(:search, "ruby", page: 2) # Checks one cached argument combination
obj.memo_count # Total cached entries for this instance
obj.memo_count(:search) # Cached entries for one method
obj.memo_keys # All cached signatures with method, args, kwargs
obj.memo_keys(:search) # Cached signatures for one method
obj.memo_values # Cached signatures and values for all methods
obj.memo_values(:search) # Cached signatures and values for one method
obj.memo_ttl_remaining(:current_quote) # => 47.231 (seconds until expiry)
obj.memo_ttl_remaining(:current_user) # => nil (no TTL set)
obj.memo_ttl_remaining(:find, 42) # => 0 (not cached or already expired)
obj.memo_age(:current_quote) # => 12.8 (seconds since cached; nil if not cached)
obj.memo_stale?(:current_quote) # => false (cached but TTL not yet elapsed)
obj.memo_stale?(:current_user) # => false (no TTL — never stale)
memo_inspect returns all metadata for one cached entry in a single mutex-held read:
obj.memo_inspect(:find, 42)
# => {
# cached: true,
# value: <result>,
# hits: 5,
# misses: 1,
# ttl_remaining: 47.2,
# age: 12.8,
# custom_key: nil,
# lru_position: 1
# }
Returns nil when the entry is not cached.
Cache metrics
Each instance tracks hits, misses, and computation time automatically.
obj.cache_stats
# => {
# total_hits: 42,
# total_misses: 8,
# hit_rate: 84.0,
# miss_rate: 16.0,
# average_computation_time: 0.012345,
# entries: [
# { method: :find, args: [1], hits: 10, misses: 1,
# hit_rate: 90.91, computation_time: 0.005 },
# ...
# ]
# }
obj.cache_stats_for(:find) # Stats scoped to one method
obj.cache_hit_rate # => 84.0 (percentage)
obj.cache_miss_rate # => 16.0 (percentage)
obj.cache_metrics_reset # Clears all collected metrics
obj.cache_metrics_reset(:find) # Clears metrics for one method only
Metrics are per-instance and reset independently from the cache itself — clearing metrics does not evict cached values.
Global configuration
Use SafeMemoize.configure to set defaults that apply to all subsequently memoized methods. Per-call options always take precedence over global defaults.
SafeMemoize.configure do |c|
c.default_ttl = 300 # All memoized methods expire after 5 minutes unless overridden
c.default_max_size = 100 # All memoized methods cap at 100 entries unless overridden
end
Both settings apply at definition time — methods already memoized before configure is called are not affected. Reset all defaults back to nil with:
SafeMemoize.reset_configuration!
The configure block also accepts on_hook_error, on_deprecation, active_support_notifications, statsd_client, and default_store (covered in Hook error isolation, Deprecation, ActiveSupport::Notifications, StatsD, and Pluggable cache stores).
ActiveSupport::Notifications
Enable opt-in integration with ActiveSupport::Notifications for Rails and other ActiveSupport-based stacks:
SafeMemoize.configure do |c|
c.active_support_notifications = true
end
Once enabled, SafeMemoize emits the following events through the standard notification pipeline:
| Event | Fires when |
|---|---|
cache_hit.safe_memoize |
A cached value is returned |
cache_miss.safe_memoize |
The method is called and no cached value exists |
cache_store.safe_memoize |
A value is written to the cache (miss, warm_memo, or load_memo) |
cache_evict.safe_memoize |
An entry is removed via reset_memo, reset_all_memos, or LRU eviction |
cache_expire.safe_memoize |
An expired TTL entry is pruned |
Each event payload includes:
{
method: :method_name, # Symbol
key: cache_key, # Array — the full cache key
class: "ClassName" # String — the host class name
}
Subscribe to all SafeMemoize events via the standard ActiveSupport pattern:
ActiveSupport::Notifications.subscribe(/\.safe_memoize$/) do |event|
Rails.logger.debug("[cache] #{event.name} #{event.payload[:class]}##{event.payload[:method]}")
end
The integration is a no-op when ActiveSupport is not loaded — there is no overhead for non-Rails projects. active_support_notifications defaults to false.
StatsD
Route cache lifecycle events to any StatsD-compatible client via SafeMemoize::Adapters::StatsD. Assign the client once in your initializer:
SafeMemoize.configure do |c|
c.statsd_client = Datadog::Statsd.new("localhost", 8125)
end
SafeMemoize then calls client.increment(metric, tags: [...]) on every cache event:
| Metric | Fires when |
|---|---|
safe_memoize.hit |
A cached value is returned |
safe_memoize.miss |
The method is called and no cached value exists |
safe_memoize.store |
A value is written to the cache (miss, warm_memo, or load_memo) |
safe_memoize.evict |
An entry is removed via reset_memo, reset_all_memos, or LRU eviction |
safe_memoize.expire |
An expired TTL entry is pruned |
Each call includes two tags: method:method_name and class:ClassName. The client must respond to increment(metric, tags: [...]) — the interface used by dogstatsd-ruby, statsd-instrument, and most modern StatsD clients.
If the client raises, the error is rescued and a warning is emitted to stderr rather than propagated to the caller. statsd_client defaults to nil (disabled).
OpenTelemetry
SafeMemoize::Adapters::OpenTelemetry wraps the computation on each cache miss in an OpenTelemetry span, making memoized call costs visible in distributed traces. Assign a tracer once in your initializer:
SafeMemoize.configure do |c|
c.opentelemetry_tracer = OpenTelemetry.tracer_provider.tracer(
"safe_memoize",
SafeMemoize::VERSION
)
end
SafeMemoize then wraps every cache miss (the actual method call, not cache hits) in a span named "safe_memoize.compute" with the following attributes:
| Attribute | Value |
|---|---|
safe_memoize.method |
Name of the memoized method |
safe_memoize.class |
Name of the host class |
safe_memoize.cache_hit |
Always false — only misses are traced |
Cache hits produce no spans, so tracing overhead is zero for cached calls. The adapter is compatible with any tracer that responds to in_span(name, attributes:, &block) — the interface provided by opentelemetry-sdk, opentelemetry-api, and no-op tracers alike. If opentelemetry_tracer is nil (the default), the adapter is completely bypassed.
Rails request-scope
SafeMemoize ships optional Rails integration as a separate require (zero overhead in non-Rails apps):
require "safe_memoize/rails"
Controller concern
Include SafeMemoize::Rails::RequestScoped in any Rails controller that also prepend SafeMemoize. It automatically registers after_action :reset_all_memos so every instance memo is cleared at the end of each request — preventing state from leaking between requests when the controller object is reused:
class ApplicationController < ActionController::Base
prepend SafeMemoize
include SafeMemoize::Rails::RequestScoped
memoize :current_user
end
Service objects and non-controller classes
In plain classes (service objects, Active Model objects), include RequestScoped to gain reset_request_memos and call it manually at the appropriate point:
class ReportService
prepend SafeMemoize
include SafeMemoize::Rails::RequestScoped
def summary(id)
# ...
end
memoize :summary
end
svc = ReportService.new
svc.summary(1)
svc.reset_request_memos # clears all instance memos
Middleware for tracked instances
For service objects that should be reset automatically at request boundaries, use the Rack middleware together with SafeMemoize::Rails.track:
# config/application.rb
config.middleware.use SafeMemoize::Rails::Middleware
class ReportService
prepend SafeMemoize
def initialize
SafeMemoize::Rails.track(self) # register for auto-reset
end
def summary(id)
# ...
end
memoize :summary
end
SafeMemoize::Rails::Middleware calls reset_all_memos on every tracked instance in the current thread at the end of the request, even if the app raises. Tracking is thread-local, so concurrent requests never interfere. The tracked list is cleared automatically after each reset.
Pluggable cache stores
By default, memoized results live in a per-instance hash — fast, but private to each object. Pass store: to route reads and writes through any external backend, enabling cross-process and distributed memoization.
Built-in: Stores::Memory
Stores::Memory is the built-in in-process store. It is used automatically by the store: default and is the reference implementation for custom adapters. You can pass your own instance to share a cache across multiple classes or to set a TTL on the shared store:
SHARED_STORE = SafeMemoize::Stores::Memory.new
class UserService
prepend SafeMemoize
def find(id) = User.find(id)
memoize :find, store: SHARED_STORE, ttl: 60
end
class PostService
prepend SafeMemoize
def (post) = User.find(post.user_id)
memoize :author, store: SHARED_STORE
end
The store is shared across all instances of a class, so the method is computed only once per unique argument set regardless of how many objects exist.
Redis adapter
Requires a Redis-compatible client (the redis gem or any drop-in replacement):
require "safe_memoize/stores/redis"
require "redis"
REDIS_STORE = SafeMemoize::Stores::Redis.new(::Redis.new)
class PricingService
prepend SafeMemoize
def quote(sku) = api_fetch(sku)
memoize :quote, store: REDIS_STORE, ttl: 300
end
Values and keys are serialized with Marshal (Base64-encoded via Array#pack("m0")). TTL is forwarded to Redis as PX (milliseconds) for sub-second precision. clear uses SCAN so it never blocks the Redis event loop. All keys are namespace-scoped (default: "safe_memoize") so multiple stores or applications can share one Redis instance:
REDIS_STORE = SafeMemoize::Stores::Redis.new(::Redis.new, namespace: "myapp:memo")
Rails.cache adapter
Wraps any ActiveSupport::Cache::Store, including Rails.cache:
require "safe_memoize/stores/rails_cache"
RAILS_STORE = SafeMemoize::Stores::RailsCache.new(Rails.cache)
class CatalogService
prepend SafeMemoize
def fetch(slug) = Catalog.find_by!(slug: slug)
memoize :fetch, store: RAILS_STORE, ttl: 600
end
Cached nil and false values are distinguished from a cache miss via a sentinel envelope, so falsy results are preserved correctly. TTL is forwarded as expires_in: for native store expiry. clear uses delete_matched scoped to the namespace.
Custom adapters
Subclass SafeMemoize::Stores::Base and implement the six-method contract:
class MyStore < SafeMemoize::Stores::Base
def read(key) = ... # return MISS if absent
def write(key, value, expires_in: nil) = ...
def delete(key) = ...
def clear = ...
def keys = ... # Array of stored keys
end
Use SafeMemoize::Stores::Base::MISS (a frozen sentinel object) as the return value from read when the key is absent — this distinguishes a cache miss from a cached nil or false.
concurrent-ruby adapter
SafeMemoize::Adapters::ConcurrentRuby replaces the default Mutex-backed store with Concurrent::Map and Concurrent::ReentrantReadWriteLock from the concurrent-ruby gem. Multiple readers proceed in parallel; writers still get exclusive access. For read-heavy hot paths this can meaningfully reduce lock contention.
concurrent-ruby is a soft dependency — it is not required at runtime unless you instantiate the adapter. Add it to your own Gemfile:
gem "concurrent-ruby"
Opt in per class:
class HotService
prepend SafeMemoize
self.safe_memoize_store = SafeMemoize::Adapters::ConcurrentRuby.new
def expensive(id) = db.find(id)
memoize :expensive
end
Or set it globally:
SafeMemoize.configure do |c|
c.default_store = SafeMemoize::Adapters::ConcurrentRuby.new
end
A LoadError with an actionable message is raised at instantiation if concurrent-ruby is not installed. The adapter is incompatible with max_size: and shared: (same constraints as all external stores).
Class-level default store (safe_memoize_store=)
Set a default store for every memoize call on a single class without touching the global configuration:
class ReportService
prepend SafeMemoize
self.safe_memoize_store = SafeMemoize::Adapters::ConcurrentRuby.new
def summary = compute_summary # routed through ConcurrentRuby
memoize :summary
end
The resolution order is: per-method store: → class-level .safe_memoize_store → global SafeMemoize.configuration.default_store. Assign nil to clear. An invalid value (not a Stores::Base instance) raises ArgumentError.
Global default store
Set a default store for all compatible memoize calls without specifying store: on each one:
SafeMemoize.configure do |c|
c.default_store = SafeMemoize::Stores::Redis.new(::Redis.new)
end
A per-method store: option always takes precedence. Methods using max_size: or shared: silently bypass the global default (LRU and shared-mode use their own storage). An invalid value raises ArgumentError at memoize time. Reset with SafeMemoize.reset_configuration!.
Compatibility
The store: option composes with ttl:, ttl_refresh:, if:, unless:, lifecycle hooks, and cache metrics. It is incompatible with max_size: (use the store adapter's own eviction) and shared: (raise ArgumentError if combined).
Deprecation
SafeMemoize ships a structured deprecation helper for gem authors who build on top of it:
SafeMemoize.deprecate(
"MyGem::OldHelper",
message: "Use MyGem::NewHelper instead",
horizon: "2.0.0"
)
# => [SafeMemoize] DEPRECATED: MyGem::OldHelper — Use MyGem::NewHelper instead (removal horizon: 2.0.0)
The warning is emitted to stderr by default. Configure a custom handler via SafeMemoize.configure:
SafeMemoize.configure do |c|
c.on_deprecation = ->(msg) { Rails.logger.warn(msg) }
end
To raise on deprecation warnings in test environments:
SafeMemoize.configure do |c|
c.on_deprecation = ->(msg) { raise msg }
end
Ractor-safe shared cache
Pass ractor_safe: true (together with shared: true) to replace the Mutex-backed class-level shared cache with a supervisor Ractor that owns the mutable cache hash. All reads and writes are serialised through message passing, so the cache is safe to use from multiple Ractors.
class PriceService
prepend SafeMemoize
def fetch_price(item_id)
external_api.get("/prices/#{item_id}")
end
memoize :fetch_price, shared: true, ractor_safe: true, ttl: 300
end
# Main Ractor — multiple threads share one cache entry
20.times.map { Thread.new { PriceService.new.fetch_price(42) } }.map(&:value)
# Worker Ractors also read from and write to the same supervisor cache
result = Ractor.new(PriceService) { |s| s.new.fetch_price(42) }.take
How it works
- A supervisor
Ractoris created once per class the first time aractor_safe: truemethod is memoized. It owns a plain RubyHashand responds to:fetch,:store,:delete_all,:delete_one,:clear,:memoized, and:countmessages. - The memoize wrapper Proc is frozen via
Ractor.make_shareablebefore being registered withdefine_method, so the class can be passed directly intoRactor.newblocks. - Cached values are deep-frozen via
Ractor.make_shareable. Values that cannot be made shareable (e.g. aMutex) raiseArgumentError. - Thread safety inside the main Ractor (multiple threads) is handled by per-call tags (
Thread.current.object_id) combined withRactor.receive_if, so concurrent threads never consume each other's replies. ttl:is supported. Expired entries are skipped by the supervisor's:fetchhandler.
Constraints
ractor_safe: true is intentionally limited. The following options are incompatible and raise ArgumentError at memoize time:
| Option | Reason |
|---|---|
if: / unless: |
Conditional Procs are non-Ractor-shareable |
max_size: |
LRU order tracking requires a non-shareable Ruby Hash |
ttl_refresh: |
Requires re-examining the record on every hit |
key: |
Custom key Procs are non-Ractor-shareable |
store: |
External adapters are incompatible with the supervisor model |
Class-level API
PriceService.ractor_memoized?(:fetch_price, 42) # → true / false
PriceService.ractor_memo_count # → total live entries
PriceService.ractor_memo_count(:fetch_price) # → entries for one method
PriceService.reset_ractor_memo(:fetch_price, 42) # → clear one entry
PriceService.reset_ractor_memo(:fetch_price) # → clear all entries for method
PriceService.reset_all_ractor_memos # → clear entire shared cache
Ractor compatibility
Regular memoize (without ractor_safe: true) is not Ractor-compatible. Passing a class that uses memoize into a Ractor.new block raises RuntimeError: defined with an un-shareable Proc in a different Ractor. There are two root causes:
Non-shareable closures.
ClassMethods#memoizebuilds anonymous modules usingdefine_methodwith blocks that close over local variables (ttl,max_size,condition,shared_mutex, …). Ruby marks those Procs as non-Ractor-shareable, so the host class cannot be sent to a Ractor.Mutable module-level state.
SafeMemoize.configurationreads@configurationfrom theSafeMemoizemodule — a mutable ivar on a shared constant — which raisesRactor::IsolationErrorfrom a non-main Ractor.
Workaround for shared caches: use memoize :method, shared: true, ractor_safe: true (see Ractor-safe shared cache above).
Workaround for per-instance caches: Use Ruby Threads instead of Ractors — SafeMemoize is fully thread-safe via double-check locking and per-instance Mutexes. If you need true parallelism with Ractors, perform computation inside the Ractor without memoization and send frozen results back via Ractor#send.
Development
After checking out the repo, run bin/setup to install dependencies. Then, run bundle exec rspec to run the tests. You can also run bin/console for an interactive prompt.
To run the benchmark suite: bundle exec ruby benchmarks/benchmark.rb.
To generate API documentation locally: bundle exec rake doc. Output is written to doc/ (gitignored). The online reference is published automatically to RubyDoc.info on every release. Install memery and memo_wise first if you want comparison columns against those gems.
GitHub Actions also runs the full bundle exec rake suite automatically for pull requests, manual workflow runs, and pushes to main via .github/workflows/ci.yml.
Releasing
Releases are automated in two parts:
- Run
bin/release VERSIONlocally to:- update
lib/safe_memoize/version.rb - convert the current
## [Unreleased]section inCHANGELOG.mdinto a dated release entry - create the release commit and annotated tag
- update
- Push the branch and tag to GitHub. The workflow in
.github/workflows/release.ymlwill:- run the test and lint suite
- build the gem
- push it to RubyGems when that version is not already published
- create a GitHub release using the matching section from
CHANGELOG.md
One-time setup:
- add a
RUBYGEMS_API_KEYrepository secret in GitHub
Typical release flow:
bundle exec rake
bin/release 0.1.1
git push origin HEAD
git push origin v0.1.1
To preview the changelog/version update without changing anything, use:
bin/release 0.1.1 --dry-run
Public API and versioning guarantee
From v1.0.0 onwards SafeMemoize follows Semantic Versioning. The table below declares every constant, method, and option key that forms the public contract. If you only call items listed here, you are guaranteed that:
- Patch releases (1.x.y) contain bug fixes only — no behaviour changes.
- Minor releases (1.x.0) add new features in a backwards-compatible way.
- Major releases (x.0.0) may break the items below; a migration guide will be published for every such release.
Anything not listed here — internal modules, private methods, @__safe_memo_*__ ivars, the structure of the cache hash itself — is subject to change without notice in any release, including patch releases.
Top-level module
| Symbol | Kind | Notes |
|---|---|---|
SafeMemoize::VERSION |
constant | Semver string, always present |
SafeMemoize::Error |
class | Base error class (< StandardError) for rescuing any SafeMemoize-raised exception |
| `SafeMemoize.configure { \ | c\ | … }` |
SafeMemoize.configuration |
module method | Returns the current Configuration |
SafeMemoize.reset_configuration! |
module method | Restores all configuration to defaults |
SafeMemoize.deprecate(subject, message:, horizon:) |
module method | Emits a structured deprecation warning |
memoize DSL (class method, added by prepend SafeMemoize)
| Option key | Type | Default | Notes |
|---|---|---|---|
ttl: |
`Numeric \ | nil` | nil |
ttl_refresh: |
Boolean |
false |
Sliding window — resets clock on every hit |
max_size: |
`Integer \ | nil` | nil |
if: |
`Symbol \ | Proc \ | nil` |
unless: |
`Symbol \ | Proc \ | nil` |
shared: |
Boolean |
false |
Class-level shared cache |
key: |
`Proc \ | nil` | nil |
store: |
`Stores::Base \ | nil` | nil |
fiber_local: |
Boolean |
false |
Fiber-local cache; each fiber gets an isolated store; incompatible with shared: and store: |
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: |
memoize_all options (class method)
All memoize option keys above, plus:
| Option key | Type | Default |
|---|---|---|
except: |
Array<Symbol> |
[] |
only: |
Array<Symbol> |
[] |
include_protected: |
Boolean |
false |
include_private: |
Boolean |
false |
Instance methods (public)
Inspection
| Method | Returns |
|---|---|
memoized?(method_name, *args, **kwargs) |
Boolean |
memo_count(method_name = nil) |
Integer |
memo_keys(method_name = nil) |
Array |
memo_values(method_name = nil) |
Array |
memo_inspect(method_name, *args, **kwargs) |
`Hash \ |
memo_ttl_remaining(method_name, *args, **kwargs) |
`Numeric \ |
memo_age(method_name, *args, **kwargs) |
`Numeric \ |
memo_stale?(method_name, *args, **kwargs) |
Boolean |
Invalidation and mutation
| Method | Returns |
|---|---|
reset_memo(method_name, *args, **kwargs) |
nil |
reset_all_memos |
nil |
memo_touch(method_name, *args, ttl: nil, **kwargs) |
Boolean |
memo_refresh(method_name, *args, **kwargs) |
cached value |
Warm-up and persistence
| Method | Returns |
|---|---|
warm_memo(method_name, *args, ttl: nil, **kwargs) |
cached value |
memo_preload(method_name, *arg_sets) |
Array |
dump_memo(method_name = nil) |
Hash |
load_memo(snapshot) |
nil |
Lifecycle hooks
| Method | Fires when |
|---|---|
| `on_memo_hit { \ | key\ |
| `on_memo_miss { \ | key\ |
| `on_memo_store { \ | key, value\ |
| `on_memo_expire { \ | key\ |
| `on_memo_evict { \ | key\ |
clear_memo_hooks(hook_type = nil) |
— |
Metrics
| Method | Returns |
|---|---|
cache_stats |
Hash |
cache_stats_for(method_name) |
Hash |
cache_hit_rate |
Float |
cache_miss_rate |
Float |
cache_metrics_reset(method_name = nil) |
nil |
Custom keys
| Method | Notes |
|---|---|
| `memoize_with_custom_key(method_name) { \ | *args, **kwargs\ |
clear_custom_keys(method_name = nil) |
Remove one or all key generators |
Fiber-local cache (when any method uses fiber_local: true)
| Method | Returns |
|---|---|
fiber_local_memoized?(method_name, *args, **kwargs) |
Boolean — cached in the current fiber? |
reset_fiber_memo(method_name, *args, **kwargs) |
nil — clear one or all entries in current fiber |
reset_all_fiber_memos |
nil — clear all fiber-local entries for this instance |
Shared-cache class methods (added when any method uses shared: true)
| Method | Returns |
|---|---|
reset_shared_memo(method_name, *args, **kwargs) |
nil |
reset_all_shared_memos |
nil |
shared_memoized?(method_name, *args, **kwargs) |
Boolean |
shared_memo_count(method_name = nil) |
Integer |
shared_memo_age(method_name, *args, **kwargs) |
`Numeric \ |
shared_memo_stale?(method_name, *args, **kwargs) |
Boolean |
Ractor-safe shared cache (added when any method uses ractor_safe: true)
| Method | Returns |
|---|---|
reset_ractor_memo(method_name, *args, **kwargs) |
nil — clear one or all entries |
reset_all_ractor_memos |
nil — clear the entire Ractor-safe shared cache |
ractor_memoized?(method_name, *args, **kwargs) |
Boolean — live entry exists? |
ractor_memo_count(method_name = nil) |
Integer — live entry count |
SafeMemoize::Configuration attributes
| Attribute | Type | Default |
|---|---|---|
default_ttl |
`Numeric \ | nil` |
default_max_size |
`Integer \ | nil` |
on_deprecation |
`Proc \ | nil` |
on_hook_error |
`Proc \ | nil` |
active_support_notifications |
Boolean |
false |
statsd_client |
`Object \ | nil` |
opentelemetry_tracer |
`Object \ | nil` |
default_store |
`Stores::Base \ | nil` |
Store adapter classes (v1.1.0+)
| Class | Require | Notes |
|---|---|---|
SafeMemoize::Stores::Base |
auto | Abstract base — subclass to build custom adapters; exposes MISS sentinel |
SafeMemoize::Stores::Memory |
auto | Built-in in-process store; reference implementation |
SafeMemoize::Stores::Redis |
"safe_memoize/stores/redis" |
Redis-backed adapter; Marshal serialization; PX TTL |
SafeMemoize::Stores::RailsCache |
"safe_memoize/stores/rails_cache" |
ActiveSupport::Cache::Store wrapper |
Opt-in extensions (not guaranteed until their owning milestone ships)
The following are available now but reside under require "safe_memoize/rails" and are not covered by the semver guarantee until the v1.x milestone that owns them is declared stable:
SafeMemoize::Railsmodule (track,reset_tracked!)SafeMemoize::Rails::RequestScopedconcernSafeMemoize::Rails::MiddlewareRack middlewareSafeMemoize::Adapters::StatsDSafeMemoize::Adapters::OpenTelemetry
Ruby version support
Supported versions
SafeMemoize requires Ruby ≥ 3.3. Every non-EOL Ruby version in the table below is actively tested in CI and receives bug-fix backports for critical issues.
| Ruby | Status | EOL |
|---|---|---|
| 3.3 | Supported | Mar 2027 |
| 3.4 | Supported | Mar 2028 |
| 4.0 | Supported | ~ Dec 2028 |
EOL dates follow the Ruby maintenance schedule.
Policy
- Dropping an EOL version is a minor-version change, not a major one — it will appear in the CHANGELOG under
### Removedand the gemspecrequired_ruby_versionwill be updated accordingly. - SafeMemoize targets the current stable release plus the two previous non-EOL minors at any given time. When Ruby releases a new version in December, CI gains a new column; when a version reaches EOL the next minor release removes it.
- No patch release will ever raise the minimum Ruby version. Only
x.y.0minor releases may do so. - Prerelease Rubies (dev / preview builds) are not officially supported but breakage is investigated on a best-effort basis.
History
| Dropped in | Ruby version removed |
|---|---|
| v0.5.0 | Ruby 3.2 (reached EOL) |
Roadmap
See ROADMAP.md for the planned path to v1.0.0 and beyond, including upcoming features, API stability goals, and the versioning policy.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/eclectic-coding/safe_memoize.
License
The gem is available as open source under the terms of the MIT License.