LaunchKit
← All posts
· 7 min read · by The LaunchKit team · 254 views

Running Rails 8 without Redis

Rails 8 removed the usual reason a small application runs two datastores. Jobs, cache and WebSockets all have adapters that live in the database you already have, so running Rails without Redis is now a default you can just accept.

Mostly you should. But one of those defaults is load-bearing for correctness: on a stock install your rate limit counter is a row in the same table as your page fragments, and its first increment can go missing.

What the solid adapters replace

Three jobs usually go to Redis. Rails 8 ships an adapter for each, storing its data in your own relational database.

  • Solid Queue is an Active Job backend. Queues, claimed executions and scheduled jobs become tables.
  • Solid Cache is an ActiveSupport::Cache::Store. Entries become rows keyed by a hash of the cache key.
  • Solid Cable is an Action Cable adapter using the database as the pubsub transport.

Turning all three on is three lines in two files.

# config/environments/production.rb
config.cache_store = :solid_cache_store
config.active_job.queue_adapter = :solid_queue

# config/cable.yml
production:
  adapter: solid_cable

Swapping one back for Redis later touches these lines and nothing else.

What a single database buys

The sharpest win is a failure mode that stops existing. A Redis configured without persistence loses every enqueued job on restart. That can't happen when the queue is a table inside a transactional database, and the restore that brings back the jobs table brings back the rows those jobs were about.

The cost is load your database didn't carry before. Cache reads, queue polling and cable broadcasts all land on the same connection pool as user queries, so one slow query that saturates the pool degrades three subsystems instead of one. Size the pool for the workers too, during the migration: three lines of config makes it look free.

Rails 8 rate limiting runs on your cache store

Here's the signature that starts the problem.

def rate_limit(to:, within:, by: -> { request.remote_ip },
               with: -> { raise TooManyRequests },
               store: cache_store, name: nil, scope: nil, **options)

store: defaults to cache_store, which resolves to config.action_controller.cache_store and falls back to config.cache_store. If you set :solid_cache_store and never thought about it again, your abuse counter is a row in PostgreSQL. Nobody decided that. It followed from two defaults agreeing with each other.

The check we're chasing is small enough to quote whole.

cache_key = ["rate-limit", scope, name, by].compact.join(":")
count = store.increment(cache_key, 1, expires_in: within)
if count && count > to

Note count &&. If the store can't produce a number, the request sails through. A rate limiter that fails open is a defensible design choice, and it's worth knowing you have one.

How Solid Cache increments a counter

Read, add, write back, inside one transaction. Here's the method we're after, and the branch it yields to.

# app/models/solid_cache/entry.rb
def lock_and_write(key, &block)
  transaction do
    without_query_cache do
      result = lock.where(key_hash: key_hash_for(key)).pick(:key, :value)
      new_value = block.call(result&.first == key ? result[1] : nil)
      write(key, new_value) if new_value
      new_value
    end
  end
end

# lib/solid_cache/store/api.rb
if entry && !entry.expired?
  ActiveSupport::Cache::Entry.new(amount + entry.value.to_i, ...)
elsif /\A\d+\z/.match?(value)
  ActiveSupport::Cache::Entry.new(amount + value.to_i, ...)
else
  ActiveSupport::Cache::Entry.new(amount, ...)
end

lock is SELECT ... FOR UPDATE in solid_cache 1.0.10, and a locking select is an implementation detail a cache is entitled to change. When the row is there it's correct: the statement takes a row lock, everyone else queues behind it, the read-modify-write serialises. For any key written once that's the only path it takes.

An expired entry lands in the last branch and restarts at amount. So does a missing one. Those two aren't the same case, and the difference is the rest of this article.

Why the first write of a key can be lost

SELECT ... FOR UPDATE locks rows that exist. A predicate matching nothing takes no lock, and PostgreSQL won't stop another transaction from inserting a row that would have matched.

Two lanes labelled Request A and Request B run the same four steps at the same time: BEGIN, then FOR UPDATE which matches no row and so locks nothing, then value equals nil, then WRITE 1. Both writes land on the same row, which ends holding the value 1.

So neither blocks, both get nil, and whichever commits second overwrites the first with the same number. Two requests happened. The counter says one.

For a cache this is a non-event. You lose a write on a cold key, take one extra miss, and ActiveSupport::Cache never promised otherwise. It only becomes interesting when something treats the store as a counter of record, which rate_limit does by default.

to: 10 admits eleven. Once per first-seen identifier, and the identifier is usually the IP, so somebody rotating addresses meets this on every single one.

How long a rate limit row actually lives

Expiry in Solid Cache is a trimming pass, not a deletion the moment an entry goes stale. It runs probabilistically on write and removes rows by age, count or size, default max_age two weeks. An entry past its expires_in keeps its row until a pass reaches it.

A timeline of one rate limit key. A narrow yellow block at first contact is labelled no row, nothing to lock, race. A wide white block covering the rest is labelled the row exists, where FOR UPDATE has something to lock and increments serialise, and notes that the three minute window expiring does not delete the row. A second narrow yellow block at plus two weeks is labelled gone, trimmed away, race.

That flips the conclusion. The key for an address seen three minutes ago still has its row, so the locking select locks it and increments serialise properly. The lost increment is confined to keys with no row at all: a first-seen identifier, or one trimmed away after a long absence. Your exposure is first contact with each address, which is exactly when an unknown address matters.

Giving rate limits a store of their own

One constant, and rate_limit already documents the keyword.

class SessionsController < ApplicationController
  LIMIT_STORE = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["RATE_LIMIT_REDIS_URL"])

  rate_limit to: 10, within: 3.minutes, store: LIMIT_STORE, only: :create
end

Any ActiveSupport::Cache::Store whose increment is atomic on an absent key will do, and a store already in your deployment beats one added for a single counter.

The better reason to split them has nothing to do with atomicity. A cache store is sized and trimmed for cache data, so under memory pressure a busy application evicts fragments and your counters go with them, on the same rule. An evicted counter is a reset counter, which makes the retention of your abuse limits a side effect of how much page cache you happen to be holding.

If you keep one store, know which of your limits depend on the first increment being exact. A login limit standing between an attacker and a credential stuffing run isn't the same risk as a limit smoothing traffic to an expensive report.

When Redis earns its place back

Tens of thousands of jobs a minute put polling and claiming load on the database serving your user requests, where a dedicated queue backend stops competing for the connection pool. Hot keys at high throughput are the same argument from the other side, since an in-memory store answers a read with no round trip. Atomic counters are the third reason and arrive far earlier than either.

What this costs you to build yourself

Three lines got you here. Knowing which of them is safe to leave alone is the slow part, and rate limiting is only the example we've just pulled apart: there's also a connection pool sized for workers as well as web requests, and a cache whose eviction pressure shouldn't be deciding your security limits.

The Rails boilerplate this site sells runs the configuration at the top of this article, for the reasons above.

#rails #infrastructure

Comments 2 comments

Ben Sheldon

Thanks for sharing!

  • it would be nice if you linked to the code
  • it would be appreciated if you linked to any existing issues or PRs that discuss this, or even opened up an Issue about it. This seems meaningful.
launchkit-codes

Thanks for reading it, and both points are fair.

On the code: it's SolidCache::Entry.lock_and_write, which does lock.where(key_hash: ...).pick(...) inside a transaction. On a key that already exists that's correct, the row lock serialises everyone.

The gap is the branch where no row exists yet: on PostgreSQL a locking select that matches no rows takes no lock, so two concurrent first increments both read nil, both compute amount, and the upsert on the unique key_hash index means the second overwrites the first. I was reading solid_cache 1.0.10. I should have linked it instead of describing it, and I'll fix that in the post.

On issues and PRs: I'd genuinely like to be pointed at prior discussion if you've seen any. I'm putting together a repro and a PR rather than just an issue, since the fix and the reproduction belong together.

I'll edit the post with the links once it's up.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.