Muninn
Versioned cache invalidation for Rails via Redis counters.
No TTL guessing — cache is invalidated automatically when data changes.
How it works
Muninn maintains monotonically incrementing version counters in Redis. Each cache key includes the current version number. When a record is created/updated/deleted, the relevant counter is bumped, the cache key changes, and stale data is never served.
Installation
gem "muninn"
Configuration
# config/initializers/muninn.rb
Muninn.configure do |config|
config.redis = Redis.new(url: ENV["REDIS_URL"])
# Scope resolver (e.g., Current.tenant, current_corretora)
config.current_scope_id = -> { Current.tenant&.id }
# Optional — user fingerprint to differentiate cache per user
config.current_user_id = -> { Current.user&.id }
# Optional — required only if using invalidates_namespace_globally
config.all_scope_ids = -> { Tenant.pluck(:id) }
# Optional — TTL for Redis version counters (default: nil = no expiry)
config.version_ttl = 7.days
# Optional — race_condition_ttl for cache writes (default: 10 seconds)
config.race_condition_ttl = 15
end
Railtie (automatic include)
Muninn's Railtie automatically includes Muninn::Cache::Invalidation in all ActiveRecord::Base models and Muninn::Cache::Caching in all ActionController::Base controllers. You do not need to add include statements unless you want to opt in selectively. If you prefer manual control, remove the Railtie or skip auto-include.
Model Invalidation
class Listing < ApplicationRecord
# Invalidation is auto-included via Railtie
# If not using Railtie: include Muninn::Cache::Invalidation
invalidates_namespace "listings" # scope: tenant-wide
invalidates_namespace "listings", scope_name: "entity", scope_id: :id # scope: individual record
# Cascade invalidation to parent
invalidates_namespace "bookings", scope_id: ->(r) { r.booking&.tenant_id }
# Polymorphic parent
invalidates_polymorphic_parent :reviewable
# Global (across all tenants, async via ActiveJob)
invalidates_namespace_globally "amenities"
end
Controller Caching
class ListingsController < ApplicationController
# Caching is auto-included via Railtie
# If not using Railtie: include Muninn::Cache::Caching
cache_defaults expires_in: 5.minutes
cache_action :index,
allowed_params: %i[city_id checkin guests page]
cache_action :show, mode: "entity"
cache_action :search,
allowed_params: %i[query city_id page],
deps_extractor: ->(params) {
params[:city_id].present? ? { "search_results" => params[:city_id] } : {}
}
# Cache per user (default: false — cache is shared within the scope)
cache_action :profile, per_user: true
end
Architecture
Request → cache_response (around_action)
├── VersionCounter.get(namespace, scope) → current version
├── KeyBuilder.fingerprint_from_params(params) → SHA256 fingerprint
│ (Rails internal params automatically filtered)
├── KeyBuilder.build(namespace, scope, ...) → SHA256 cache key
├── Rails.cache.read(key) → HIT? render cached
└── MISS? yield → Rails.cache.write(key, response) (only on 200)
Model.save → after_commit :bump_cache_namespaces
└── VersionCounter.bump(namespace, scope) → Redis INCR (atomic pipeline)
DSL Reference
| Method | Description |
|---|---|
invalidates_namespace |
Bump a version counter on save |
invalidates_polymorphic_parent |
Bump parent's counter via polymorphic association |
invalidates_namespace_globally |
Bump counter across all scopes (async, batched) |
cache_action |
Enable response caching for a controller action |
cache_defaults |
Set default options for all cached actions |
cache_action options
| Option | Default | Description |
|---|---|---|
expires_in |
nil |
Cache TTL |
allowed_params |
[] |
Whitelist of params in fingerprint |
mode |
"list" |
Cache mode: "list", "entity", or custom |
per_user |
false |
Include user_id in cache key |
version_namespace |
controller_name |
Version counter namespace |
deps_extractor |
nil |
Lambda to extract dependency versions |
race_condition_ttl |
10 |
Stale cache serving window |
Configuration options
| Option | Default | Description |
|---|---|---|
redis |
required | Redis client instance |
current_scope_id |
nil |
Current tenant/client scope ID (lambda) |
current_user_id |
nil |
Current user ID for per-user cache (lambda) |
all_scope_ids |
[] |
All scope IDs for global invalidation (lambda) |
default_scope_name |
"default" |
Default scope column name |
version_ttl |
nil |
TTL for Redis version counters (nil = no expiry) |
race_condition_ttl |
10 |
Seconds to serve stale cache during regeneration |
Instrumentation
Muninn emits ActiveSupport::Notifications events for observability:
| Event | Payload | Fires |
|---|---|---|
cache_hit.muninn |
key, action |
Cache HIT in controller |
cache_miss.muninn |
key, action |
Cache MISS in controller |
cache_write.muninn |
key, action |
Cache write on 200 response |
version_counter.get.muninn |
namespace, scope_name, scope_id |
Version read |
version_counter.bump.muninn |
namespace, scope_name, scope_id |
Version increment |
invalidation.bump.muninn |
namespace, scope_name, scope_id |
Model invalidation |
invalidation.global.muninn |
namespaces, scope_name |
Global invalidation triggered |
ActiveSupport::Notifications.subscribe(/\.muninn/) do |event|
Rails.logger.info "[Muninn] #{event.name}: #{event.payload.inspect}"
end
Error handling
If Redis is unavailable, Muninn logs a warning via Rails.logger and falls back to uncached behavior (requests pass through normally).
License
MIT