Skip to content

eclectic-coding/safe_memoize

Repository files navigation

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

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

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)

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

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.

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.

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

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

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.

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]

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 memoize_with_custom_key on an instance to control exactly what makes two calls equivalent:

class ReportService
  prepend SafeMemoize

  def generate(user_id, options)
    build_report(user_id, options)
  end
  memoize :generate
end

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 ignored

The 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]}
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)

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

Metrics are per-instance and reset independently from the cache itself — clearing metrics does not evict cached values.

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.

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:

  1. Run bin/release VERSION locally to:
    • update lib/safe_memoize/version.rb
    • convert the current ## [Unreleased] section in CHANGELOG.md into a dated release entry
    • create the release commit and annotated tag
  2. Push the branch and tag to GitHub. The workflow in .github/workflows/release.yml will:
    • 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_KEY repository 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

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.

About

Thread-safe memoization for Ruby that correctly handles nil and false values.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors