rails_named_cache

Gem Version CI MIT License

Multiple named cache stores for Rails, through Rails.cache(:name).

Rails.cache.fetch("users") { User.all }            # default store, unchanged
Rails.cache(:memory).fetch("countries") { ... }    # in-process, ~3 µs per read
Rails.cache(:redis).fetch("session") { ... }       # shared across servers

Why:

  • Speed. A named MemoryStore read is ~3 µs — no socket, no pool checkout, no wire protocol (numbers).
  • Isolation. Per-domain stores, so page-fragment churn can no longer evict the pricing table. Rails.cache(:pricing).clear touches nothing else.
  • No new API. Rails.cache(:name) returns the configured store itself — no wrapper, no proxy. Stores live in config/ instead of global constants; Rails.cache is untouched.

Contents: Install · Configuration · Usage · Expiration · L1/L2 · MemoryStore speed · Testing · Advanced · FAQ · Be careful · Compatibility

Installation

bundle add rails_named_cache
rails generate rails_named_cache:install   # optional: a commented initializer to start from

Ruby >= 3.3, Rails >= 7.0. Runtime deps are activesupport, railties, concurrent-ruby.

Configuration

One setting. Values are any ActiveSupport::Cache::Store:

# config/initializers/rails_named_cache.rb
Rails.application.configure do
  config.named_cache_stores = {
    memory: ActiveSupport::Cache::MemoryStore.new(size: 1.megabyte),
    pricing: ActiveSupport::Cache::MemoryStore.new(size: 8.megabytes),
    redis: ActiveSupport::Cache::RedisCacheStore.new(url: ENV.fetch("REDIS_URL")),
    disk: ActiveSupport::Cache::FileStore.new(Rails.root.join("tmp/cache")),
    null: ActiveSupport::Cache::NullStore.new
  }
end

Rails shorthand works too, resolved through ActiveSupport::Cache.lookup_store:

config.named_cache_stores = {
  memory: :memory_store,
  redis: [:redis_cache_store, { url: ENV.fetch("REDIS_URL") }],
  disk: [:file_store, "tmp/cache"]
}

Works from config/application.rb or any environment file too. An app that never sets it behaves exactly as before. Per-environment stores, engines, boot ordering, error semantics and the registry API are in Advanced configuration.

Usage

Rails.cache.fetch("key") { value }                                  # default store, unchanged
Rails.cache(:memory).fetch("countries") { Country.all.to_a }
Rails.cache(:pricing).write("product/1", price)
Rails.cache(:null).fetch("anything") { computed }                   # never caches

store = Rails.cache(:memory)   # a plain ActiveSupport::Cache::Store — full API, nothing wrapped

Unknown and nil names raise instead of falling back to another store:

Rails.cache(:unknown)
# RailsNamedCache::UnknownStoreError: Unknown named cache store :unknown
#
#   Available stores:
#   :memory
#   :pricing

Rails.cache(nil)
# RailsNamedCache::ConfigurationError: Cache store name must be a non-empty Symbol or String, got nil

UnknownStoreError exposes #name and #available.

Expiration

Per-store defaults, so each domain gets its own policy:

config.named_cache_stores = {
  memory: ActiveSupport::Cache::MemoryStore.new(size: 1.megabyte, expires_in: 10.seconds),
  pricing: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), expires_in: 1.hour }],
  geocode: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), expires_in: 30.days }]
}

Per-call overrides are ordinary ActiveSupport:

Rails.cache(:memory).fetch("flags", expires_in: 1.second) { Flag.enabled.to_a }
Rails.cache(:pricing).write("product/#{id}", price, expires_in: 30.seconds)
Rails.cache(:pricing).fetch("rate", expires_in: 1.hour, race_condition_ttl: 10.seconds) { fetch_rate }
Rails.cache(:pricing).fetch("report", expires_at: Date.tomorrow.midnight) { build_report }

No expires_in on a store means entries live until overwritten, deleted, or evicted by the backend.

L1/L2: memory in front of Redis

Not a gem feature — two named stores and two nested fetch calls:

# app/lib/layered_cache.rb
module LayeredCache
  def self.fetch(key, l1_expires_in: 5.seconds, **options, &block)
    Rails.cache(:memory).fetch(key, expires_in: l1_expires_in) do
      Rails.cache(:redis).fetch(key, **options, &block)
    end
  end

  def self.delete(key)
    Rails.cache(:redis).delete(key)
    Rails.cache(:memory).delete(key)   # this process only
  end
end
LayeredCache.fetch("countries", expires_in: 1.day) { Country.order(:name).to_a }
# L1 hit (< 5s): ~3 µs, no Redis. L1 miss: one Redis read, refills L1. Both miss: runs the block.

The L1 TTL is your staleness window. An L1 entry cannot be invalidated from another process, so delete clears Redis at once while other workers keep serving their copy until it expires. Keep it at seconds.

Why MemoryStore is so fast

Redis / Memcached MemoryStore
Network round trip yes no
Connection pool checkout yes no
Wire protocol encode/decode yes no
Value handling serialize to bytes deep-copy in memory

Reading a small hash 200,000 times, Ruby 3.4.5 on Apple silicon:

MemoryStore: 362,918 reads/sec —  2.8 µs/read
FileStore:    51,202 reads/sec — 19.5 µs/read

Redis adds a socket round trip on top of the same bookkeeping. What is left in MemoryStore is the deep copy that keeps callers from mutating cached state (dup for strings, Marshal otherwise), so keep values small and plain.

The cost: it is per-process and dies with the process. Use it for cheap-to-recompute, read-constantly data; use a shared store for anything that must agree across processes.

Testing

# disable caching for one domain, restore after
config.around(:each, :no_pricing_cache) do |example|
  original = Rails.cache(:pricing)
  RailsNamedCache.register(:pricing, ActiveSupport::Cache::NullStore.new)
  example.run
  RailsNamedCache.register(:pricing, original)
end

config.before { Rails.cache(:pricing).clear }   # or clear one store between examples

Advanced configuration

Boot order

Stores are registered after config/initializers run and before eager loading, so eager-loaded code can call Rails.cache(:name).

Not available inside config/initializers — a lookup in an initializer body raises UnknownStoreError. Use config.after_initialize, to_prepare, or application code.

Per-environment stores are plain Rails configuration:

# config/environments/test.rb
config.named_cache_stores = { pricing: ActiveSupport::Cache::NullStore.new }

Isolating stores that share a backend

Two stores on the same Redis, Memcached or directory share one keyspace — different objects, same keys:

Rails.cache(:short).write("user/1", "a")
Rails.cache(:long).read("user/1")   # => "a"   — not isolated
Rails.cache(:long).clear            # also wipes :short

Give each a namespace. That isolates the keys and makes the store identifiable in logs and APM — the cache_read.active_support payload carries namespace, otherwise both report only store: "ActiveSupport::Cache::RedisCacheStore":

config.named_cache_stores = {
  short: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), namespace: "short", expires_in: 1.minute }],
  long: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), namespace: "long", expires_in: 1.day }]
}

Separate MemoryStore instances need no namespace.

Engines

Do not set config.named_cache_stores from an engine — railtie config is shared, so it replaces the host app's stores. Register instead:

initializer "my_engine.caches", after: "rails_named_cache.register_stores" do
  RailsNamedCache.register(:my_engine, ActiveSupport::Cache::MemoryStore.new)
end

Configuration errors fail at boot

Nothing is registered if any entry is invalid:

config.named_cache_stores = { pricing: nil }
# ConfigurationError: Named cache store :pricing is nil

config.named_cache_stores = { "pricing" => a, pricing: b }
# ConfigurationError: Duplicate named cache store :pricing

nil is rejected because ActiveSupport::Cache.lookup_store(nil) returns a fresh MemoryStore — a typo would become a cache that quietly loses data. Names are non-empty symbols or strings, and "memory" and :memory are the same store.

Registry API

RailsNamedCache.register(:memory, ActiveSupport::Cache::MemoryStore.new)
RailsNamedCache.fetch(:memory)      # => the store (UnknownStoreError if missing)
RailsNamedCache.registered?(:redis) # => false
RailsNamedCache.names               # => [:memory]
RailsNamedCache.registry.to_h       # => { memory: #<MemoryStore> }
RailsNamedCache.reset!              # empties the registry (tests)

Registration is by name, so stores registered by hand from an initializer survive boot unless config.named_cache_stores names the same store.

FAQ

Why not global constants? Configuration ends up outside config/, tests stub constants, and nothing lists which caches exist.

Forking servers (Puma workers, Unicorn, preload_app!)? Same caveat as config.cache_store: stores are built at boot, so with preloading they exist before the fork. Pass connection-based stores as configuration ([:redis_cache_store, { url: ... }]) rather than a live client — Rails connects lazily per worker, while a pre-connected socket shared across forks corrupts.

Do named stores get the per-request local cache? No. Rails inserts LocalCache::Middleware for the default Rails.cache only, so two identical reads in one request hit the backend twice. Nothing leaks — the local cache is inert — and you can opt in with Rails.cache(:redis).with_local_cache { ... }.

Overhead? One Concurrent::Map read per lookup, lock-free. Stores are instantiated once at boot.

Outside Rails? RailsNamedCache.register / .fetch work anywhere; Rails.cache(:name) and config.named_cache_stores need an application. Requiring the gem always loads rails/railtie, so load order never matters.

Built-in L1/L2? No — five lines in app code, and a built-in version would have to guess your staleness window. Rails.cache(:memory, fallback: :redis) stays possible later without breaking the API.

Be careful

Named stores make it easy to add caches, which makes it easy to add these:

  • Memory multiplies. MemoryStore defaults to size: 32.megabytes when you omit it — five named stores is up to 160 MB per process, times your Puma workers, times your servers. Set size: on every MemoryStore explicitly and size it against the container limit, not the host.
  • A MemoryStore is one lock. A Monitor guards reads and writes, so all threads in the process serialize on it; the ~3 µs above is single-threaded. Pruning also runs inside that lock (bounded by max_prune_time, default 2 s), so an oversized store can stall every thread. Prefer several small stores over one huge hot one.
  • TTL does not reclaim memory. Expired entries are dropped when read or on cleanup; eviction happens on write once the store exceeds size, pruning back to 75%. A store full of expired entries nobody reads still occupies RAM.
  • No cross-process invalidation. Rails.cache(:memory).delete(key) and .clear affect the calling process only — a console or rake task cannot flush the running workers. Anything that needs a coordinated purge belongs in a shared backend.
  • Two stores on one backend share one keyspace, and clear on either wipes both. See Isolating stores that share a backend.
  • No per-request local cache. Repeated reads of the same key in one request hit a named store's backend every time. On a Redis-backed store, wrap the work in with_local_cache or put a memory layer in front.
  • Cache plain data, not objects. Storing ActiveRecord instances or anything class-bound in a long-lived in-process store outlives code reloading in development and keeps stale classes alive. Arrays, hashes, strings and numbers stay cheap to deep-copy too.
  • Never take a store name from user input. Rails.cache(params[:store]) raises, and the error message lists every registered store name.
  • Do not create a store per model. Splitting one working set across many small caches lowers the overall hit rate, and every configured store is built at boot whether anything reads it or not. Add a store when a value genuinely needs a different backend, size or TTL — otherwise Rails.cache is still the right answer.
  • Pass connection-based stores as configuration, not as a live client object, or a preloading forking server will share one socket across workers.

Compatibility

| | | | --- | --- | | Ruby | 3.3, 3.4, 4.0 | | Rails | 7.0, 7.1, 7.2, 8.0, 8.1 |

Every combination runs in CI. Note the Ruby floor: an older Rails is only supported on a modern Ruby, so a Rails 7.0 app still on Ruby 3.1 cannot install this gem.

Rails 6.0 and 6.1 work but are neither supported nor tested — the runtime uses no API newer than Rails 6.0, so relaxing the constraint locally is enough.

Development

bundle exec rake          # specs + rubocop

BUNDLE_GEMFILE=gemfiles/rails_7.0.gemfile bundle exec rspec   # one Rails version
ls gemfiles/*.gemfile     # rails_7.0 … rails_8.1

The suite boots a real (tiny) Rails app in a tmpdir, so boot wiring is covered end to end. Design rules live in AGENTS.md.

Contributing

Bug reports and pull requests welcome at https://github.com/igorkasyanchuk/rails_named_cache. Keep bundle exec rake green and add a spec for anything that changes behaviour.

License

MIT — see LICENSE.