External Cache Stores

In-memory caching (Layer 3 from the previous lesson) is per-process. On a multi-server deployment, each web process has its own isolated cache — so a policy result cached on server A won’t be seen by server B. For shared, persistent caching across all your servers, you need an external cache store.

Configuring an External Store

Action Policy uses your Rails application’s cache_store by default, but you can configure a dedicated store:

config/environments/production.rb
config.action_policy.cache_store = :redis_cache_store, {
url: ENV["REDIS_URL"],
expires_in: 1.hour
}

Any object that responds to #read(key) and #write(key, value, **options) works as a cache store — including all ActiveSupport cache stores.

TTL Support

When using an external store, you’ll want cache entries to expire. Pass expires_in directly to the cache declaration:

class ProductPolicy < ApplicationPolicy
cache :show?, expires_in: 1.hour
cache :update?, expires_in: 30.minutes
def show?
record.published? || record.user_id == user&.id
end
def update?
user.teams.exists?(product_id: record.id)
end
end

The expires_in value is forwarded to your cache store’s write call. If the store doesn’t support TTLs (like the default memory store), it’s silently ignored.

Cache Invalidation Strategies

Cached authorization results can become stale if the underlying data changes — a user’s role changes, a product is archived, a team membership is revoked. There are three main approaches:

Let the cache keys do the work. Action Policy builds keys from cache_key (which includes updated_at on ActiveRecord models). Updating a record automatically busts its cache entries:

# Changing the user's role updates updated_at
user.update!(role: "admin")
# Next policy check builds a new cache key — old entry is ignored

This works well when the objects that affect authorization are also ActiveRecord records.

2. Discard All (Version-Based Invalidation)

Override cache_namespace on your policy with a version string stored somewhere mutable (e.g., Rails cache, database):

class ApplicationPolicy < ActionPolicy::Base
def cache_namespace
"v#{Rails.cache.read("policy_version") || 1}"
end
end
# To invalidate everything:
Rails.cache.increment("policy_version")

This is a blunt instrument — it expires every cached policy result at once — but it’s simple and reliable for cases like “user roles just changed”.

3. Selective Invalidation

Delete specific cache entries when access rules change:

# After revoking team membership:
Rails.cache.delete_matched("action_policy/**/teams/#{team.id}/**")

This requires knowing the key pattern used by Action Policy. Use it carefully, as cache key patterns can be brittle.

Per-Thread Memoization

Even with an external cache store, each request still creates multiple policy instances. The ThreadMemoized behaviour takes Layer 1 (per-instance memoization) further by sharing a single policy instance across all calls within the same thread:

# config/application.rb or an initializer
require "action_policy/behaviours/thread_memoized"
class ApplicationController < ActionController::Base
include ActionPolicy::Behaviours::ThreadMemoized
end

With ThreadMemoized, the first allowed_to?(:update?, product) call in a request builds and caches a policy object. Every subsequent call in that request (across different controllers, views, or helpers) reuses the same instance.

Clean up at the end of each request to avoid leaking between requests:

class ApplicationController < ActionController::Base
include ActionPolicy::Behaviours::ThreadMemoized
after_action { ActionPolicy::PerThreadCache.clear_all }
end

Overriding cache_namespace

The default namespace is "action_policy". Override it per-policy for fine-grained control:

class ProductPolicy < ApplicationPolicy
def cache_namespace
# Include the app version to bust cache on deploy
"store/v#{Rails.application.config.version}"
end
end

Custom cache_key on Models

If your model’s default cache_key doesn’t capture all the fields that affect authorization, define a custom one:

class User < ApplicationRecord
# Include role in the cache key so role changes bust policy caches
def policy_cache_key
"users/#{id}-#{role}-#{updated_at.to_i}"
end
end

Action Policy checks for policy_cache_key first, then falls back to cache_key, then id, then object_id.

Summary

LayerScopeMechanismWhen to use
Instance memoizationPer-request, per-recordBehaviours::MemoizedAlways (default)
Rule memoizationPer-request, per-instanceCachedApplyAlways (default)
Persistent cacheCross-request, in-memorycache :rule?Single-server apps
Persistent cacheCross-request, sharedcache :rule? + external storeMulti-server production
Thread memoizationPer-request, across instancesBehaviours::ThreadMemoizedHigh-frequency policy calls

Start with the defaults and add cache :rule? declarations as you identify hot paths. Reach for external stores and thread memoization only when profiling confirms you need them.

Powered by WebContainers