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
- How It Works
- Installation
- Usage
- Basic memoization
- With arguments
- Nil and false safety
- Works with private methods
- Cache reset
- Cache invalidation groups
- Lifecycle hooks
- TTL expiration
- Sliding window TTL
- LRU cache size limit
- Conditional caching
- Cache warm-up and persistence
- Shared cache
- Fiber-local memoization
- Bulk memoization
- Custom cache keys
- Cache inspection
- Cache metrics
- Plugin / extension architecture
- Automatic cache busting
- Named shared caches
- Cache namespacing
- Global configuration
- ActiveSupport::Notifications
- StatsD
- OpenTelemetry
- Rails request-scope
- Pluggable cache stores
- Deprecation
- Circuit breaker for external stores
- Multi-level caching
- Stampede protection
- Per-class default options
- Copy-on-read
- Ractor-safe shared cache
- Ractor compatibility
- Development
- Releasing
- Public API and versioning guarantee
- Ruby version support
- Benchmarks
- Roadmap
- Contributing
- License
Ruby's common memoization pattern breaks with falsy values:
def user
@user ||= find_user # Re-runs find_user every time it returns nil!
endSafeMemoize uses Hash#key? to distinguish "not yet cached" from "cached nil/false", so your methods are only computed once regardless of return value.
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.
Add to your Gemfile:
gem "safe_memoize"Then run:
bundle installOr install directly:
gem install safe_memoizeclass UserService
prepend SafeMemoize
def current_user
# This expensive lookup runs only once
User.find_by(session_id: session_id)
end
memoize :current_user
endCalling memoize on a method name that does not exist raises ArgumentError immediately at class definition time rather than at the first runtime call.
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.
class Config
prepend SafeMemoize
def enabled?
# Only called once, even though it returns false
ENV["FEATURE_FLAG"] == "true"
end
memoize :enabled?
endclass TokenProvider
prepend SafeMemoize
def bearer_token
token
end
private
def token
fetch_token_from_service
end
memoize :token
endobj = 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 valuesTag related methods with group: and bust them all at once with a single reset_memo_group call:
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 β unaffectedFor shared: true methods, use the class method:
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 methodssvc.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]Use safe_memoize_options to assign all subsequently memoized methods to the same group:
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
endA method belongs to at most one group at a time; re-memoizing with a different group: moves it.
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}")
endon_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(", ")})")
endon_memo_hit fires on every cache hit:
obj.on_memo_hit do |cache_key, record|
StatsD.increment("cache.hit", tags: ["method:#{cache_key[0]}"])
endon_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]}")
endon_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}")
endMultiple 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 hooksHooks are per-instance and do not affect other objects of the same class.
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 })
}
endSet c.on_hook_error = :raise to re-raise exceptions instead of swallowing them.
class QuoteService
prepend SafeMemoize
def current_quote
fetch_quote_from_api
end
memoize :current_quote, ttl: 60
endWith 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 expiredUse 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 combinationAdd 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
endWithout 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.
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
endCache 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 3max_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: 300The on_evict hook fires for LRU-evicted entries the same way it does for manual reset_memo calls.
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? }
endclass DataService
prepend SafeMemoize
# Don't cache error responses
def fetch(key)
api_client.get(key)
end
memoize :fetch, unless: ->(result) { result.is_a?(ErrorResponse) }
endBoth options accept any callable and compose with ttl: and max_size::
memoize :find, if: ->(result) { !result.nil? }, ttl: 60, max_size: 500Use 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"])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.
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 rawLoaded entries have no TTL β they persist until explicitly reset. Expired entries are excluded from dump_memo output, so snapshots never contain stale data.
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 recomputationClass-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: 500Hooks (on_memo_hit, on_memo_miss, on_memo_expire, on_memo_evict) fire on the calling instance as usual.
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 cachefiber_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 instanceLifecycle 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.
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
endAll 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: trueInherited methods are never affected regardless of visibility.
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, options)
build_report(user_id, options)
end
memoize :generate, key: ->(user_id, _options) { 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 keyFor 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, _options| user_id }
svc.generate(42, {format: :pdf}) # computes and caches
svc.generate(42, {format: :csv}) # cache hit β same user_id, options ignoredThe block can return any comparable value β a scalar, array, or hash:
svc.memoize_with_custom_key(:generate) do |user_id, options|
{user: user_id, locale: options[:locale]}
endCustom 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 generatorsobj = 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.
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 onlyMetrics are per-instance and reset independently from the cache itself β clearing metrics does not evict cached values.
SafeMemoize::Extension lets third-party gems add custom memoize options and global lifecycle handlers without monkey-patching SafeMemoize internals.
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:
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
endThe processor block must return a Hash of standard memoize option keys to inject. Any standard option is supported:
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 } endon_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
endValid event types: :on_hit, :on_miss, :on_store, :on_expire, :on_evict.
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) # β MyExtensionAn extension does not need to extend SafeMemoize::Extension. Any object responding to handled_options, process_memoize_option, and dispatch_cache_event is accepted.
- Unknown
memoizekeywords raiseArgumentErrorunless a registered extension claims them β typos are still caught. on_cache_eventhandlers run on the main Ractor only; they are silently skipped from worker Ractors.
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.
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# 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] }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)orreset_all_memosexplicitly.
For unbounded caches, pair with ttl: or a max_size:-capable store to limit memory growth:
memoize :summary, cache_bust: -> { @order.updated_at }, ttl: 3600All introspection methods work with the current token:
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- Incompatible with
key:β both define the cache key shape; raisesArgumentErroratmemoizetime. - Composes with
namespace:,ttl:,if:,unless:, andshared_cache:.
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.
class OrderService
prepend SafeMemoize
def find(id) = Order.find(id)
memoize :find, shared_cache: "orders"
end
class OrderPresenter
prepend SafeMemoize
def find(id) = Order.find(id) # same method signature
memoize :find, shared_cache: "orders" # same backing store
end
# After OrderService.new.find(42) computes the value, OrderPresenter.new.find(42)
# returns the cached result β the method body is not called a second time.SafeMemoize.shared_cache("orders") # get or auto-create a Memory store
SafeMemoize.register_shared_cache("orders", my_redis_store) # use a custom adapter
SafeMemoize.clear_shared_cache("orders") # evict all entries
SafeMemoize.drop_shared_cache("orders") # remove from registry
SafeMemoize.shared_caches # { "orders" => #<Memory>, β¦ }
SafeMemoize.reset_shared_caches! # wipe registry (test teardown)Register a Redis-backed (or any Stores::Base) store before any class that references the name is loaded β the store is captured at memoize definition time:
# config/initializers/safe_memoize.rb
require "safe_memoize/stores/redis"
SafeMemoize.register_shared_cache(
"orders",
SafeMemoize::Stores::Redis.new(Redis.new, namespace: "myapp:orders")
)By default two classes sharing the same cache name and method name share the same key:
# OrderService#find(42) and OrderPresenter#find(42) β same key [:find, [42], {}]Add namespace: when you want class-scoped entries within the same store:
memoize :find, shared_cache: "orders", namespace: "service" # [:"service:find", [42], {}]
memoize :find, shared_cache: "orders", namespace: "presenter" # [:"presenter:find", [42], {}]- Incompatible with
shared:,store:,fiber_local:,ractor_safe:, andmax_size:(use the store adapter's own eviction policy). register_shared_cachemust be called before the class that uses the name is defined.- Test suites should call
SafeMemoize.reset_shared_caches!in anafterhook to prevent state leaking between examples.
Namespacing adds a string prefix to every cache key, scoping entries to a logical partition. It is transparent to the rest of the API β introspection methods always accept and return bare method names regardless of the active namespace.
Namespacing is particularly useful for:
- Versioned deployments β change the namespace to instantly invalidate all in-flight cached values without flushing the whole store.
- Multi-tenant applications β scope keys per tenant so different tenants' data cannot collide, even when sharing the same in-process hash or external store.
Pass namespace: to a single memoize call:
class ApiClient
prepend SafeMemoize
def fetch(id) = http_get(id)
memoize :fetch, namespace: "v2" # keys: [:"v2:fetch", [id], {}]
endSet .safe_memoize_namespace= to apply a namespace to every memoize call on the class that doesn't specify its own:
class OrderService
prepend SafeMemoize
self.safe_memoize_namespace = "orders"
def find(id) = Order.find(id)
memoize :find # keys: [:"orders:find", ...]
def stats = compute_stats
memoize :stats, namespace: "v2" # per-method wins β [:"v2:stats", ...]
endSet via SafeMemoize.configure to apply a namespace to every memoized method in the process that has no per-method or class-level namespace:
SafeMemoize.configure do |c|
c.namespace = "v1.2.3" # bump this string on each deploy to bust all cached values
endnamespace: option on memoize > .safe_memoize_namespace on the class > SafeMemoize.configuration.namespace
- Namespace strings must be non-empty and must not contain
:. - Namespacing works with all memoize paths (standard,
store:,fiber_local:,shared:,ractor_safe:). - Adding or changing a namespace changes the cache keys, so existing entries become unreachable (they expire naturally or can be cleared by
reset_all_memos).
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
endBoth 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, default_store, and namespace (covered in Hook error isolation, Deprecation, ActiveSupport::Notifications, StatsD, Pluggable cache stores, and Cache namespacing).
Enable opt-in integration with ActiveSupport::Notifications for Rails and other ActiveSupport-based stacks:
SafeMemoize.configure do |c|
c.active_support_notifications = true
endOnce 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]}")
endThe integration is a no-op when ActiveSupport is not loaded β there is no overhead for non-Rails projects. active_support_notifications defaults to false.
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)
endSafeMemoize 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).
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
)
endSafeMemoize 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.
SafeMemoize ships optional Rails integration as a separate require (zero overhead in non-Rails apps):
require "safe_memoize/rails"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
endIn 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 memosFor 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::Middlewareclass ReportService
prepend SafeMemoize
def initialize
SafeMemoize::Rails.track(self) # register for auto-reset
end
def summary(id)
# ...
end
memoize :summary
endSafeMemoize::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.
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.
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 author(post) = User.find(post.user_id)
memoize :author, store: SHARED_STORE
endThe 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.
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
endValues 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")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
endCached 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.
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
endUse 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.
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
endOr set it globally:
SafeMemoize.configure do |c|
c.default_store = SafeMemoize::Adapters::ConcurrentRuby.new
endA 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).
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
endThe 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.
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)
endA 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!.
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).
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) }
endTo raise on deprecation warnings in test environments:
SafeMemoize.configure do |c|
c.on_deprecation = ->(msg) { raise msg }
endSafeMemoize::Stores::CircuitBreaker wraps any Stores::Base adapter and silently falls back to the per-instance in-process cache when the external store is unavailable, rather than propagating exceptions to callers.
The breaker moves through three states:
| State | Behaviour |
|---|---|
:closed |
Normal β every call passes through to the wrapped store; consecutive errors are counted |
:open |
Tripped β reads return MISS (triggering the per-instance fallback), writes are no-ops; no calls reach the store until the probe interval elapses |
:half_open |
Probe β calls are let through; the first success closes the circuit; any failure re-opens it and resets the timer |
Any successful call in :closed state resets the consecutive error counter, so transient blips do not accumulate toward the threshold.
Direct wrapping:
redis = SafeMemoize::Stores::CircuitBreaker.new(
MyRedisStore.new,
error_threshold: 5, # trip after 5 consecutive failures (default)
probe_interval: 30 # wait 30 s before probing (default)
)
class CatalogService
prepend SafeMemoize
def products = fetch_from_redis
memoize :products, store: redis
endVia the circuit_breaker: option (auto-wraps the configured store):
class CatalogService
prepend SafeMemoize
def products = fetch_from_redis
memoize :products, store: MyRedisStore.new, circuit_breaker: true
def orders = fetch_orders
memoize :orders,
store: MyRedisStore.new,
circuit_breaker: { error_threshold: 3, probe_interval: 60 }
endWhen the store raises, products falls back to the per-instance in-memory hash β callers see no exceptions and computation still runs once per instance until the circuit closes.
cb = SafeMemoize::Stores::CircuitBreaker.new(store)
cb.state # => :closed | :open | :half_open
cb.open? # => false
cb.error_count # => 0
cb.error_threshold # => 5
cb.probe_interval # => 30.0
cb.wrapped_store # => the inner adapter
cb.reset! # manually close the circuit and clear error countclass ApiService
prepend SafeMemoize
safe_memoize_options circuit_breaker: { error_threshold: 3, probe_interval: 15 }
def users = http.get("/users")
def orders = http.get("/orders")
memoize :users, store: redis
memoize :orders, store: redis # both get the circuit breaker
endSafeMemoize::Stores::Multilevel chains two or more store adapters from fastest (L1) to slowest. Reads walk the chain until a hit is found; on a miss in an earlier layer the value is fetched from the next layer and written back ("promoted") into all shallower layers. Writes and deletes reach every layer.
l1 = SafeMemoize::Stores::Memory.new # fast, in-process
l2 = MyRedisStore.new # slower, cross-process
class ProductService
prepend SafeMemoize
def catalog = fetch_catalog_from_db
memoize :catalog, store: [l1, l2], ttl: 300 # Array shorthand
endThe store: [l1, l2] shorthand is equivalent to store: Stores::Multilevel.new(l1, l2).
By default promoted entries have no TTL (the L1 store's own eviction β e.g. LRU β handles memory bounds). Set promote_expires_in: to give L1 entries a shorter lifetime than L2:
store = SafeMemoize::Stores::Multilevel.new(l1, l2, promote_expires_in: 60)
memoize :catalog, store: store, ttl: 300Multilevel composes with CircuitBreaker and XFetch:
safe_l2 = SafeMemoize::Stores::CircuitBreaker.new(MyRedisStore.new)
store = SafeMemoize::Stores::Multilevel.new(SafeMemoize::Stores::Memory.new, safe_l2)
memoize :catalog, store: store, ttl: 300Cache stampedes (a.k.a. thundering-herd) happen when a popular entry expires and many processes simultaneously recompute it. SafeMemoize offers two mechanisms:
The stampede_protection: option applies the XFetch algorithm to the per-instance in-process cache. Instead of expiring at a hard deadline, each cache read probabilistically triggers early recomputation as the entry approaches its TTL:
class ApiClient
prepend SafeMemoize
def catalog = fetch_catalog
memoize :catalog, ttl: 300, stampede_protection: true # default beta=1.0
# or
memoize :catalog, ttl: 300, stampede_protection: 2.0 # custom beta (more aggressive)
endThe measured computation time from each cache miss is stored as delta and used in subsequent reads, so the XFetch probability adapts to real observed latency automatically.
Requirements: ttl: must be set. Incompatible with store: (see Stores::XFetch below) and ractor_safe:.
For external stores (Redis, Rails.cache, etc.) wrap the adapter with Stores::XFetch. Values are stored with an expires_at envelope so the wrapper can apply the formula even though the store's read returns only the plain value:
store = SafeMemoize::Stores::XFetch.new(
MyRedisStore.new,
delta: 0.2, # estimated typical computation time (seconds)
beta: 1.5 # aggressiveness scalar
)
class CatalogService
prepend SafeMemoize
def products = db_fetch
memoize :products, store: store, ttl: 300
endXFetch formula: now β (delta Γ beta Γ log(rand)) β₯ expires_at
A higher beta triggers early recomputation more aggressively. A larger delta (longer computation) also increases the recomputation window.
Stores::XFetch composes with Multilevel and CircuitBreaker:
store = SafeMemoize::Stores::XFetch.new(
SafeMemoize::Stores::CircuitBreaker.new(MyRedisStore.new),
delta: 0.1
)safe_memoize_options sets option defaults for every memoize call on the class, eliminating repetition when many methods share the same TTL, LRU cap, or other option. Per-call options still take precedence; class defaults take precedence over global SafeMemoize.configure defaults.
class ApiClient
prepend SafeMemoize
safe_memoize_options ttl: 60, max_size: 200, copy_on_read: true
def fetch(id) = http.get(id)
memoize :fetch # uses ttl: 60, max_size: 200, copy_on_read: true
def list = http.get("/all")
memoize :list, ttl: 300 # uses max_size: 200, copy_on_read: true; ttl: 300 overrides
endAccepted options are the same as memoize minus the mode-switch options (shared:, fiber_local:, ractor_safe:, shared_cache:), which must be specified per call because they change the entire execution path:
safe_memoize_options(
ttl: 60,
max_size: 100,
ttl_refresh: true,
copy_on_read: true,
namespace: "v2",
if: ->(v) { v.present? },
cache_bust: :updated_at
)Call with no arguments to clear all class-level defaults:
MyClass.safe_memoize_options # clears β subsequent memoize calls use global config or per-call options onlyPass copy_on_read: true to memoize to return a dup (or deep_dup when available, e.g. ActiveRecord objects) of the stored value on every cache read. This prevents callers from mutating the shared cached object:
class ConfigService
prepend SafeMemoize
def settings = {host: "localhost", port: 8080}
memoize :settings, copy_on_read: true
end
svc = ConfigService.new
result = svc.settings
result[:host] = "mutated" # only affects the caller's copy
svc.settings[:host] # => "localhost" β cache is unaffectednil and frozen values are returned as-is (no dup attempted). copy_on_read: works across all cache paths: per-instance hash, LRU (max_size:), class-level shared (shared: true), fiber-local (fiber_local: true), and external stores. It is incompatible with ractor_safe: true (ractor-safe values are always frozen; rely on that guarantee instead).
Set it as a class-wide default with safe_memoize_options:
class ReportService
prepend SafeMemoize
safe_memoize_options copy_on_read: true
def summary = build_summary
memoize :summary
def details = build_details
memoize :details
endPass 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- 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.
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 |
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 cacheRegular 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.
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. Pre-recorded results and analysis are in benchmarks/BENCHMARK.md.
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.
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.1To preview the changelog/version update without changing anything, use:
bin/release 0.1.1 --dry-runFrom 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.
| 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| β¦ } |
module method | Yields Configuration; sets global defaults |
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 |
SafeMemoize.shared_cache(name) |
module method | Returns named store, auto-creating a Memory store if absent |
SafeMemoize.register_shared_cache(name, store) |
module method | Registers a custom Stores::Base under a name |
SafeMemoize.clear_shared_cache(name) |
module method | Evicts all entries from the named store |
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 |
| Option key | Type | Default | Notes |
|---|---|---|---|
ttl: |
Numeric | nil |
nil |
Seconds until entry expires |
ttl_refresh: |
Boolean |
false |
Sliding window β resets clock on every hit |
max_size: |
Integer | nil |
nil |
LRU entry limit per method |
if: |
Symbol | Proc | nil |
nil |
Store only when truthy |
unless: |
Symbol | Proc | nil |
nil |
Store only when falsy |
shared: |
Boolean |
false |
Class-level shared cache |
key: |
Proc | nil |
nil |
Class-level custom key generator |
store: |
Stores::Base | nil |
nil |
External cache store adapter; incompatible with max_size: and shared: |
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: |
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: |
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 |
circuit_breaker: |
true | Hash | nil |
nil |
Wraps the configured store: in a Stores::CircuitBreaker; true uses defaults (error_threshold: 5, probe_interval: 30); pass a Hash to customise; requires a store to be set; does not double-wrap |
stampede_protection: |
true | Numeric | nil |
nil |
Enables XFetch probabilistic early expiry on the per-instance cache; true uses beta=1.0; pass a Numeric for a custom beta; requires ttl:; incompatible with store: and ractor_safe: |
| (extension options) | any | β | Unknown kwargs are validated against registered extensions; raise ArgumentError if unclaimed |
All memoize option keys above, plus:
| Option key | Type | Default |
|---|---|---|
except: |
Array<Symbol> |
[] |
only: |
Array<Symbol> |
[] |
include_protected: |
Boolean |
false |
include_private: |
Boolean |
false |
| 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:, group:, circuit_breaker:, stampede_protection:; raises ArgumentError for shared:, fiber_local:, ractor_safe:, shared_cache: |
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 | nil |
memo_ttl_remaining(method_name, *args, **kwargs) |
Numeric | nil |
memo_age(method_name, *args, **kwargs) |
Numeric | nil |
memo_stale?(method_name, *args, **kwargs) |
Boolean |
Invalidation and mutation
| 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 |
|---|---|
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| β¦ } |
cache hit |
on_memo_miss { |key| β¦ } |
cache miss |
on_memo_store { |key, value| β¦ } |
value written |
on_memo_expire { |key| β¦ } |
TTL expires |
on_memo_evict { |key| β¦ } |
LRU eviction |
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| β¦ } |
Instance-level key generator |
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 |
| Method | Returns |
|---|---|
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 |
| 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 |
|---|---|
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 |
| Attribute | Type | Default |
|---|---|---|
default_ttl |
Numeric | nil |
nil |
default_max_size |
Integer | nil |
nil |
on_deprecation |
Proc | nil |
nil (writes to stderr) |
on_hook_error |
Proc | nil |
nil (warns to stderr) |
active_support_notifications |
Boolean |
false |
statsd_client |
Object | nil |
nil |
opentelemetry_tracer |
Object | nil |
nil |
default_store |
Stores::Base | nil |
nil |
namespace |
String | nil |
nil |
| 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 |
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
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.
- 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.
| Dropped in | Ruby version removed |
|---|---|
| v0.5.0 | Ruby 3.2 (reached EOL) |
See ROADMAP.md for the planned path to v1.0.0 and beyond, including upcoming features, API stability goals, and the versioning policy.
Bug reports and pull requests are welcome on GitHub at https://github.com/eclectic-coding/safe_memoize.
The gem is available as open source under the terms of the MIT License.