Muninn

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