Finishing the Redis Eviction: Solid Cache and Solid Cable in Production
Finishing the Redis Eviction: Solid Cache and Solid Cable in Production
If you followed the Kamal runbook off Heroku and replaced Sidekiq with Solid Queue, there's a good chance you're still paying for a Redis instance that now does almost nothing. Rails.cache still points at it. Action Cable still points at it. Two config files stand between you and cancelling the add-on.
The swap itself is small. The operational profile on the other side is not identical to Redis, and that's the part worth understanding before you cut over.
The install, in full
New Rails 8 apps ship with both gems in the Gemfile, so for most people the work is configuration rather than dependency management. (On an app upgraded from Rails 7, bundle add solid_cache solid_cable first.)
bin/rails solid_cache:install
bin/rails solid_cable:install
The Solid Cache installer rewrites config.cache_store in config/environments/production.rb, writes config/cache.yml, and generates db/cache_schema.rb (or db/cache_structure.sql if you run schema_format = :sql). Solid Cable writes config/cable.yml and db/cable_schema.rb.
What the installers don't do is add the database entries. You do that yourself:
# config/database.yml
production:
primary: &primary_production
<<: *default
url: <%= ENV["DATABASE_URL"] %>
cache:
<<: *primary_production
url: <%= ENV["CACHE_DATABASE_URL"] %>
migrations_paths: db/cache_migrate
cable:
<<: *primary_production
url: <%= ENV["CABLE_DATABASE_URL"] %>
migrations_paths: db/cable_migrate
Then bin/rails db:prepare on deploy, which creates the secondary databases and loads their schema files if they don't exist yet. Kamal users: this belongs in a pre-deploy hook or your entrypoint, not in a one-off you'll forget on the next server.
Give the cache its own database
You can point Solid Cache at your primary database, and for a small app that's a defensible starting point. But note what the README says happens when you set none of database, databases, or connects_to: Solid Cache falls back to the ActiveRecord::Base connection pool, which means cache reads and writes become part of any wrapping database transaction. Write to the cache inside a transaction that later rolls back, and the cache write rolls back too, Redis would have kept it. That's a semantic difference, not just a performance one, and it's the single best argument for a dedicated connection.
The other argument is churn. A cache is a table where nearly every row is written, read a few times, and deleted. On PostgreSQL that's a steady stream of dead tuples on one table, which means autovacuum pressure and IO you'd rather not have sharing a disk and a query planner with your orders table. On a separate database you can tune autovacuum aggressively for that workload without worrying about the blast radius.
Eviction is FIFO, and it's sampled
This is the part most people get wrong on the first pass, because Redis trained everyone to think in terms of LRU and a hard maxmemory.
Solid Cache's documented defaults: max_age is 2.weeks.to_i, max_size is nil, max_entries is nil, expiry_batch_size is 100, expiry_method is :thread. Expiry is triggered by writes, a counter increments on each write, and once it reaches 50% of the batch size a task is added. If the cache is idle, the background thread is idle too. Nothing sweeps on a timer.
Three consequences:
Set a bound. With max_size and max_entries both unset, max_age alone governs, and a two-week window on a busy app is a lot of rows. Set one:
# config/cache.yml
production:
database: cache
store_options:
max_age: <%= 30.days.to_i %>
max_size: <%= 32.gigabytes %>
namespace: <%= Rails.env %>
size_estimate_samples: 1000
The size check is an estimate. Solid Cache doesn't run SELECT SUM(byte_size) on every write; it approximates the row count from the ID range, pulls the largest rows out as outliers, and extrapolates the rest from a random sample of the key_hash space (size_estimate_samples). Treat max_size as a target the cache orbits, not a ceiling it respects to the byte. Provision disk with headroom.
Eviction is oldest-first, not least-recently-used. The README is explicit that this is a FIFO cache and that the trade is mitigated by a longer cache lifespan. A hot key written two weeks ago and read a thousand times a day is a deletion candidate; a cold key written this morning is not. FIFO is cheap and predictable to operate, and misses on hot keys refill immediately. But if you cache something expensive to regenerate and rarely written, size the window for that case rather than assuming LRU will protect it.
If your write bursts make the expiry thread a problem, expiry_method: :job moves the work to Active Job, which now runs on Solid Queue anyway.
Solid Cable is a polling loop
Solid Cable writes broadcasts to a table and has subscribers poll for them. The defaults: polling_interval 0.1 seconds, message_retention 1 day, autotrim true, trim_batch_size 100, use_skip_locked true, silence_polling true.
# config/cable.yml
production:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.day
Two things follow. First, the adapter lazily creates a single listener thread that polls for every registered channel at once, so polling cost scales with the number of processes holding subscriptions, not with the number of connected clients, a hundred WebSocket clients on one Puma process is one polling loop; ten Puma processes is ten. Budget accordingly, and raise polling_interval if 100ms of latency isn't load-bearing. Most Turbo Streams UIs are fine at half a second.
Second, autotrimming does deletes on broadcast, the README notes it attempts to trim twice as many expired messages as it writes, and warns this can negatively impact performance slightly depending on your workload. If broadcast latency matters more than tidiness, set autotrim: false and schedule SolidCable::TrimJob as a recurring Solid Queue task instead. Drop message_retention well below a day unless you have a reason to keep that much history; new subscribers start from the current head, so nobody replays it.
use_skip_locked needs MySQL 8+ or PostgreSQL 9.5+. On anything older, turn it off. (SQLite is unaffected, its writes are sequential.)
One upside worth naming: PostgreSQL's native LISTEN/NOTIFY adapter caps payloads at 8kb. Solid Cable has no such limit, so large broadcasts that forced you onto Redis in the first place are fine here.
What actually breaks
Atomic counters. Rails' built-in rate_limit, and anything else built on Rails.cache.increment, was getting a single atomic Redis command. Against Solid Cache it's a lock-and-write cycle against the row inside a transaction. Correct, but not a microsecond. If you rate limit a hot public endpoint at volume, measure before assuming parity.
Redis-shaped code. Anything reaching past Rails.cache to a raw connection, a distributed lock, a sorted set, a SCAN over a key pattern, has no equivalent here. Grep for Redis.new and REDIS_URL before you cancel anything.
clear semantics. The default clear_with is :truncate outside the test environment, where it's :delete. If your production role is locked down, set clear_with: :delete.
The cutover
Deploy with a fresh namespace so you start cold rather than reading stale entries, and take the miss storm during a quiet window. Watch cache row count, cache database disk, and p95 broadcast latency for a full traffic cycle. Then remove the Redis accessory from deploy.yml, delete REDIS_URL, and deploy.
Two config files, one db:prepare, and a line item off the bill.
Sources: rails/solid_cache README, rails/solid_cable README, Solid Cache in Rails 8: When the Database Is the Right Cache, Solid Cable in Production with Kamal