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 · Logging · 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.

Hit/miss logging

Every cache read is logged to Rails.logger at debug level — nothing to configure. It lands in the app log next to the rest of the request, log/development.log locally, and stays quiet in production, where the level is info:

Cache hit: users/1
Cache miss: pricing:eur

To turn it off, or to send it somewhere other than the app log:

config.named_cache_logger = false                                          # log nothing
config.named_cache_logger = Logger.new(Rails.root.join("log/cache.log"))   # a separate file

Two things to know:

  • This is a subscriber on the cache_read.active_support notification ActiveSupport already emits — no store is wrapped, and reads through the default Rails.cache are logged too.
  • The line carries the key, not the store: ActiveSupport's payload has nothing that identifies the instance a read came from. On Rails 7.2 and up the key is the normalized one, so give stores a namespace: and its prefix tells them apart. Rails 7.0 and 7.1 report the key as the caller wrote it, without the namespace.

Outside Rails, subscribe by hand:

RailsNamedCache::Logging.subscribe(my_logger)
RailsNamedCache::Logging.unsubscribe

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

Same names and the same backends in test — only the target changes. Every name the code calls has to exist in every environment, since a missing one raises UnknownStoreError and never falls back. Keep each store on the backend it uses in production, pointed at a test-only Redis database, so a :redis store is always Redis and tests exercise real serialization and expiry:

# config/initializers/rails_named_cache.rb
Rails.application.configure do
  # a separate Redis database in test, so a suite can never touch development keys
  redis_url = Rails.env.test? ? ENV.fetch("REDIS_TEST_URL") : ENV.fetch("REDIS_URL")

  config.named_cache_stores = {
    memory: ActiveSupport::Cache::MemoryStore.new(size: 1.megabyte, expires_in: 1.day),
    pricing: [:redis_cache_store, { url: redis_url, namespace: "pricing" }],
    redis: [:redis_cache_store, { url: redis_url, namespace: "app" }]
  }
end

Namespace every Redis store. RedisCacheStore#clear runs FLUSHDB when the store has no namespace, so the clear-between-examples hook below would wipe the whole database — including Sidekiq queues or a colleague's development data if the suite ever runs against a shared server. With a namespace, clear deletes only that store's keys.

To disable one domain's caching in test, register a NullStore for that name — see the per-example swap below — rather than backing a :redis name with a memory store, which makes the suite pass against behaviour production never runs.

Initializers load after config/environments/*.rb, so an initializer that assigns config.named_cache_stores silently overwrites whatever config/environments/test.rb set. Pick one: branch on Rails.env in the initializer, as above, or delete the initializer and configure each environment file.

Clear every store between examples, so cached state cannot leak from one example to the next:

config.before { RailsNamedCache.names.each { |name| Rails.cache(name).clear } }

Do not call RailsNamedCache.reset! in an application suite — registration happens once at boot, so an emptied registry stays empty and every later lookup raises. It exists for testing this gem.

Swap one store for one example:

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

Re-registering is better than stubbing Rails.cache: a receive(:cache).with(:pricing) stub also intercepts the no-argument calls that Rails itself makes.

Assert against the store directly — it is a real store, so no mocks are needed:

it "caches the computed price" do
  expect { PriceCalculator.call(product) }
    .to change { Rails.cache(:pricing).exist?("product/#{product.id}") }.from(false).to(true)
end

Minitest is the same, from setup / teardown in ActiveSupport::TestCase.

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. Worse, a RedisCacheStore with no namespace implements clear as FLUSHDB — the whole database, not just your keys. 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.