Skip to content

KaykyM2411/muninn

Repository files navigation

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

About

Versioned cache invalidation via Redis counters for Rails

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages