muninn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dfeee41c42813ad108e5e2611e2a392f01959f64bcbebd4ba25c7502749a2723
4
+ data.tar.gz: 91f9a92ac38d099c573548537c61a45cd3691bba4f4b3c3566fa2af818ff5184
5
+ SHA512:
6
+ metadata.gz: c09a47e4e863d379b6cded5c0d1b7073a69ebdc23ade2627a122e0a320b8f9bf26f3543f7160fe63ca7f6f7ef62b09630d206929631311135109e47cd027ed42
7
+ data.tar.gz: 86b13369400bc89676f14c9a70409444378fa0502bb1293c2929ccc5bf0135c84565445e7de31b08cac815b5325b8468cab2d0a4cf7fe5f40eeba6c31e86e5f5
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-06-23)
4
+
5
+ - Initial release
6
+ - VersionCounter: Redis-based version counters (get/bump)
7
+ - KeyBuilder: SHA256 cache key construction with fingerprint, deps, mode
8
+ - Invalidation: Declarative model concern for automatic cache invalidation
9
+ - Caching: Controller concern for HTTP response caching
10
+ - GlobalInvalidationJob: Async job for cross-tenant invalidation
11
+ - Railtie: Automatic inclusion in ActiveRecord and ActionController
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,171 @@
1
+ <p align="center">
2
+ <img src="logo.png" alt="Muninn" width="200">
3
+ </p>
4
+
5
+ <h1 align="center">Muninn</h1>
6
+
7
+ <p align="center">
8
+ <em>Versioned cache invalidation for Rails via Redis counters.</em><br>
9
+ No TTL guessing — cache is invalidated automatically when data changes.
10
+ </p>
11
+
12
+ ## How it works
13
+
14
+ 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.
15
+
16
+ ## Installation
17
+
18
+ ```ruby
19
+ gem "muninn"
20
+ ```
21
+
22
+ ## Configuration
23
+
24
+ ```ruby
25
+ # config/initializers/muninn.rb
26
+ Muninn.configure do |config|
27
+ config.redis = Redis.new(url: ENV["REDIS_URL"])
28
+
29
+ # Scope resolver (e.g., Current.tenant, current_corretora)
30
+ config.current_scope_id = -> { Current.tenant&.id }
31
+
32
+ # Optional — user fingerprint to differentiate cache per user
33
+ config.current_user_id = -> { Current.user&.id }
34
+
35
+ # Optional — required only if using invalidates_namespace_globally
36
+ config.all_scope_ids = -> { Tenant.pluck(:id) }
37
+
38
+ # Optional — TTL for Redis version counters (default: nil = no expiry)
39
+ config.version_ttl = 7.days
40
+
41
+ # Optional — race_condition_ttl for cache writes (default: 10 seconds)
42
+ config.race_condition_ttl = 15
43
+ end
44
+ ```
45
+
46
+ ## Railtie (automatic include)
47
+
48
+ 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.
49
+
50
+ ## Model Invalidation
51
+
52
+ ```ruby
53
+ class Listing < ApplicationRecord
54
+ # Invalidation is auto-included via Railtie
55
+ # If not using Railtie: include Muninn::Cache::Invalidation
56
+
57
+ invalidates_namespace "listings" # scope: tenant-wide
58
+ invalidates_namespace "listings", scope_name: "entity", scope_id: :id # scope: individual record
59
+
60
+ # Cascade invalidation to parent
61
+ invalidates_namespace "bookings", scope_id: ->(r) { r.booking&.tenant_id }
62
+
63
+ # Polymorphic parent
64
+ invalidates_polymorphic_parent :reviewable
65
+
66
+ # Global (across all tenants, async via ActiveJob)
67
+ invalidates_namespace_globally "amenities"
68
+ end
69
+ ```
70
+
71
+ ## Controller Caching
72
+
73
+ ```ruby
74
+ class ListingsController < ApplicationController
75
+ # Caching is auto-included via Railtie
76
+ # If not using Railtie: include Muninn::Cache::Caching
77
+
78
+ cache_defaults expires_in: 5.minutes
79
+
80
+ cache_action :index,
81
+ allowed_params: %i[city_id checkin guests page]
82
+
83
+ cache_action :show, mode: "entity"
84
+
85
+ cache_action :search,
86
+ allowed_params: %i[query city_id page],
87
+ deps_extractor: ->(params) {
88
+ params[:city_id].present? ? { "search_results" => params[:city_id] } : {}
89
+ }
90
+
91
+ # Cache per user (default: false — cache is shared within the scope)
92
+ cache_action :profile, per_user: true
93
+ end
94
+ ```
95
+
96
+ ## Architecture
97
+
98
+ ```
99
+ Request → cache_response (around_action)
100
+ ├── VersionCounter.get(namespace, scope) → current version
101
+ ├── KeyBuilder.fingerprint_from_params(params) → SHA256 fingerprint
102
+ │ (Rails internal params automatically filtered)
103
+ ├── KeyBuilder.build(namespace, scope, ...) → SHA256 cache key
104
+ ├── Rails.cache.read(key) → HIT? render cached
105
+ └── MISS? yield → Rails.cache.write(key, response) (only on 200)
106
+
107
+ Model.save → after_commit :bump_cache_namespaces
108
+ └── VersionCounter.bump(namespace, scope) → Redis INCR (atomic pipeline)
109
+ ```
110
+
111
+ ## DSL Reference
112
+
113
+ | Method | Description |
114
+ |---|---|
115
+ | `invalidates_namespace` | Bump a version counter on save |
116
+ | `invalidates_polymorphic_parent` | Bump parent's counter via polymorphic association |
117
+ | `invalidates_namespace_globally` | Bump counter across all scopes (async, batched) |
118
+ | `cache_action` | Enable response caching for a controller action |
119
+ | `cache_defaults` | Set default options for all cached actions |
120
+
121
+ ### `cache_action` options
122
+
123
+ | Option | Default | Description |
124
+ |---|---|---|
125
+ | `expires_in` | `nil` | Cache TTL |
126
+ | `allowed_params` | `[]` | Whitelist of params in fingerprint |
127
+ | `mode` | `"list"` | Cache mode: `"list"`, `"entity"`, or custom |
128
+ | `per_user` | `false` | Include `user_id` in cache key |
129
+ | `version_namespace` | `controller_name` | Version counter namespace |
130
+ | `deps_extractor` | `nil` | Lambda to extract dependency versions |
131
+ | `race_condition_ttl` | `10` | Stale cache serving window |
132
+
133
+ ### Configuration options
134
+
135
+ | Option | Default | Description |
136
+ |---|---|---|
137
+ | `redis` | required | Redis client instance |
138
+ | `current_scope_id` | `nil` | Current tenant/client scope ID (lambda) |
139
+ | `current_user_id` | `nil` | Current user ID for per-user cache (lambda) |
140
+ | `all_scope_ids` | `[]` | All scope IDs for global invalidation (lambda) |
141
+ | `default_scope_name` | `"default"` | Default scope column name |
142
+ | `version_ttl` | `nil` | TTL for Redis version counters (nil = no expiry) |
143
+ | `race_condition_ttl` | `10` | Seconds to serve stale cache during regeneration |
144
+
145
+ ## Instrumentation
146
+
147
+ Muninn emits `ActiveSupport::Notifications` events for observability:
148
+
149
+ | Event | Payload | Fires |
150
+ |---|---|---|
151
+ | `cache_hit.muninn` | `key`, `action` | Cache HIT in controller |
152
+ | `cache_miss.muninn` | `key`, `action` | Cache MISS in controller |
153
+ | `cache_write.muninn` | `key`, `action` | Cache write on 200 response |
154
+ | `version_counter.get.muninn` | `namespace`, `scope_name`, `scope_id` | Version read |
155
+ | `version_counter.bump.muninn` | `namespace`, `scope_name`, `scope_id` | Version increment |
156
+ | `invalidation.bump.muninn` | `namespace`, `scope_name`, `scope_id` | Model invalidation |
157
+ | `invalidation.global.muninn` | `namespaces`, `scope_name` | Global invalidation triggered |
158
+
159
+ ```ruby
160
+ ActiveSupport::Notifications.subscribe(/\.muninn/) do |event|
161
+ Rails.logger.info "[Muninn] #{event.name}: #{event.payload.inspect}"
162
+ end
163
+ ```
164
+
165
+ ## Error handling
166
+
167
+ If Redis is unavailable, Muninn logs a warning via `Rails.logger` and falls back to uncached behavior (requests pass through normally).
168
+
169
+ ## License
170
+
171
+ MIT
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Muninn
4
+ module Cache
5
+ module Caching
6
+ extend ActiveSupport::Concern
7
+
8
+ RAILS_INTERNAL_PARAMS = %w[controller action format _method authenticity_token].freeze
9
+
10
+ included do
11
+ class_attribute :cache_defaults_config, default: {}
12
+ class_attribute :cacheable_actions, default: {}
13
+ around_action :cache_response
14
+ end
15
+
16
+ class_methods do
17
+ def cache_defaults(**opts)
18
+ self.cache_defaults_config = cache_defaults_config.merge(opts)
19
+ end
20
+
21
+ def cache_action(action_name, **opts)
22
+ config = cache_defaults_config.merge(opts)
23
+ self.cacheable_actions = cacheable_actions.merge(action_name.to_s => config)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def cache_response
30
+ config = self.class.cacheable_actions[action_name]
31
+ return yield unless config
32
+
33
+ version_scope = cache_scope
34
+ resolved_mode = resolved_mode(config)
35
+
36
+ if resolved_mode == "entity" && params[:id].present?
37
+ version_scope = { scope_name: "entity", scope_id: params[:id] }
38
+ end
39
+
40
+ fingerprint = KeyBuilder.fingerprint_from_params(
41
+ params: cache_params_payload,
42
+ allowed_keys: config[:allowed_params] || [],
43
+ context: fingerprint_context(version_scope)
44
+ )
45
+
46
+ identity_namespace = "#{controller_name}_#{action_name}"
47
+ version_namespace = config[:version_namespace] || controller_name
48
+
49
+ version = VersionCounter.get(
50
+ namespace: version_namespace,
51
+ scope_name: version_scope[:scope_name],
52
+ scope_id: version_scope[:scope_id]
53
+ )
54
+
55
+ entity_id = params[:id] if resolved_mode == "entity" && params[:id].present?
56
+
57
+ key = KeyBuilder.build(
58
+ namespace: identity_namespace,
59
+ scope: version_scope,
60
+ version: version,
61
+ deps: config[:deps_extractor]&.call(cache_params_payload) || {},
62
+ fingerprint: fingerprint,
63
+ mode: resolved_mode,
64
+ entity_id: entity_id
65
+ )
66
+
67
+ cached = Rails.cache.read(key)
68
+ if cached
69
+ Muninn.instrument("cache_hit", key: key, action: action_name)
70
+ render json: cached and return
71
+ end
72
+
73
+ Muninn.instrument("cache_miss", key: key, action: action_name)
74
+ yield
75
+
76
+ return unless response.status == 200
77
+
78
+ Muninn.instrument("cache_write", key: key, action: action_name)
79
+ Rails.cache.write(key, response_body_json, expires_in: config[:expires_in])
80
+ rescue Redis::BaseError => e
81
+ Rails.logger.warn "[Muninn] Redis error in cache_response: #{e.message}"
82
+ yield unless performed?
83
+ end
84
+
85
+ def cache_scope
86
+ { scope_name: Muninn.configuration.default_scope_name, scope_id: Muninn.current_scope_id }
87
+ end
88
+
89
+ def fingerprint_context(scope)
90
+ context = { scope_id: scope[:scope_id] }
91
+ config = self.class.cacheable_actions[action_name] || {}
92
+ context[:user_id] = Muninn.current_user_id if config[:per_user]
93
+ context.compact
94
+ end
95
+
96
+ def resolved_mode(config)
97
+ if config.key?(:mode)
98
+ config[:mode]
99
+ elsif action_name == "show"
100
+ "entity"
101
+ else
102
+ "list"
103
+ end
104
+ end
105
+
106
+ def cache_params_payload
107
+ payload = if params.respond_to?(:to_unsafe_h)
108
+ params.to_unsafe_h
109
+ else
110
+ request.request_parameters
111
+ end
112
+ payload.except(*RAILS_INTERNAL_PARAMS)
113
+ end
114
+
115
+ def response_body_json
116
+ JSON.parse(response.body)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Muninn
4
+ module Cache
5
+ class GlobalInvalidationJob < ActiveJob::Base
6
+ queue_as :default
7
+
8
+ BATCH_SIZE = 100
9
+
10
+ def perform(namespaces, scope_name)
11
+ ids = Muninn.all_scope_ids
12
+ expires_in = Muninn.configuration.version_ttl
13
+ ids.each_slice(BATCH_SIZE) do |batch|
14
+ batch.each do |scope_id|
15
+ namespaces.each do |ns|
16
+ VersionCounter.bump(
17
+ namespace: ns,
18
+ scope_name: scope_name,
19
+ scope_id: scope_id,
20
+ expires_in: expires_in
21
+ )
22
+ end
23
+ end
24
+ end
25
+ rescue Redis::BaseError => e
26
+ Rails.logger.warn "[Muninn] Redis error in GlobalInvalidationJob: #{e.message}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Muninn
4
+ module Cache
5
+ module Invalidation
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :cache_namespace_rules, default: []
10
+ class_attribute :cache_invalidation_callback_set, default: false
11
+ end
12
+
13
+ class_methods do
14
+ def invalidates_namespace(*names, scope_name: nil, scope_id: nil)
15
+ scope_name ||= Muninn.configuration.default_scope_name
16
+ rules = names.map do |namespace|
17
+ { namespace: namespace.to_s, scope_name: scope_name.to_s, scope_id: scope_id }
18
+ end
19
+ self.cache_namespace_rules += rules
20
+ setup_cache_callback
21
+ end
22
+
23
+ def invalidates_polymorphic_parent(association_name, namespaces: nil, scope_name: nil)
24
+ scope_name ||= Muninn.configuration.default_scope_name
25
+ self.cache_namespace_rules += [{
26
+ polymorphic_parent: association_name.to_s,
27
+ namespaces: namespaces,
28
+ scope_name: scope_name.to_s
29
+ }]
30
+ setup_cache_callback
31
+ end
32
+
33
+ def invalidates_namespace_globally(*names, scope_name: nil)
34
+ scope_name ||= Muninn.configuration.default_scope_name
35
+ self.cache_namespace_rules += [{
36
+ global: true,
37
+ namespaces: names.map(&:to_s),
38
+ scope_name: scope_name.to_s
39
+ }]
40
+ setup_cache_callback
41
+ end
42
+
43
+ private
44
+
45
+ def setup_cache_callback
46
+ return if cache_invalidation_callback_set
47
+ self.cache_invalidation_callback_set = true
48
+ after_commit :bump_cache_namespaces
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def bump_cache_namespaces
55
+ self.class.cache_namespace_rules.each do |rule|
56
+ if rule[:polymorphic_parent]
57
+ bump_polymorphic_parent(rule)
58
+ elsif rule[:global]
59
+ bump_global_namespace(rule)
60
+ else
61
+ bump_single_namespace(rule)
62
+ end
63
+ end
64
+ rescue Redis::BaseError => e
65
+ Rails.logger.warn "[Muninn] Redis error in bump_cache_namespaces: #{e.message}"
66
+ end
67
+
68
+ def bump_single_namespace(rule)
69
+ scope_id = resolve_scope_id(rule[:scope_id], rule[:scope_name])
70
+ return if scope_id.blank?
71
+
72
+ Muninn.instrument("invalidation.bump", namespace: rule[:namespace], scope_name: rule[:scope_name], scope_id: scope_id) do
73
+ VersionCounter.bump(
74
+ namespace: rule[:namespace],
75
+ scope_name: rule[:scope_name],
76
+ scope_id: scope_id,
77
+ expires_in: Muninn.configuration.version_ttl
78
+ )
79
+ end
80
+ end
81
+
82
+ def bump_polymorphic_parent(rule)
83
+ parent = send(rule[:polymorphic_parent])
84
+ return unless parent
85
+
86
+ base = parent.class.model_name.route_key
87
+ namespaces = rule[:namespaces] || [base]
88
+
89
+ scope_id_field = "#{rule[:scope_name]}_id"
90
+ scope_id = parent.respond_to?(scope_id_field) ? parent.send(scope_id_field) : nil
91
+ return if scope_id.blank?
92
+
93
+ namespaces.each do |ns|
94
+ Muninn.instrument("invalidation.bump", namespace: ns, scope_name: rule[:scope_name], scope_id: scope_id) do
95
+ VersionCounter.bump(
96
+ namespace: ns,
97
+ scope_name: rule[:scope_name],
98
+ scope_id: scope_id,
99
+ expires_in: Muninn.configuration.version_ttl
100
+ )
101
+ end
102
+ end
103
+ end
104
+
105
+ def bump_global_namespace(rule)
106
+ Muninn.instrument("invalidation.global", namespaces: rule[:namespaces], scope_name: rule[:scope_name])
107
+ GlobalInvalidationJob.perform_later(rule[:namespaces], rule[:scope_name])
108
+ end
109
+
110
+ def resolve_scope_id(scope_id, scope_name = nil)
111
+ if scope_id.nil?
112
+ return send("#{scope_name}_id") if scope_name && respond_to?("#{scope_name}_id")
113
+ return Muninn.current_scope_id if scope_name == Muninn.configuration.default_scope_name
114
+ return nil
115
+ end
116
+ return scope_id.call(self) if scope_id.respond_to?(:call)
117
+ return send(scope_id) if scope_id.is_a?(Symbol) && respond_to?(scope_id)
118
+ scope_id
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+
6
+ module Muninn
7
+ module Cache
8
+ class KeyBuilder
9
+ def self.build(namespace:, scope:, deps: {}, fingerprint: nil, version: nil, mode: nil, entity_id: nil)
10
+ scope_name = scope.fetch(:scope_name)
11
+ scope_id = scope.fetch(:scope_id)
12
+ version ||= VersionCounter.get(namespace: namespace, scope_name: scope_name, scope_id: scope_id)
13
+ deps_versioned = build_deps(deps)
14
+ parts = []
15
+ parts << "scope:#{scope_name}:#{scope_id}"
16
+ parts << "namespace:#{namespace}"
17
+ parts << "entity:#{entity_id}" if entity_id
18
+ parts << "mode:#{mode}" if mode
19
+ parts << "deps:#{deps_versioned}" if deps_versioned
20
+ parts << "fingerprint:#{fingerprint}" if fingerprint
21
+ parts << "version:#{version}"
22
+ canonical = parts.join(":")
23
+ "cache:#{Digest::SHA256.hexdigest(canonical)}"
24
+ end
25
+
26
+ def self.fingerprint_from_params(params:, allowed_keys: [], context: {})
27
+ payload = normalize_payload(params, allowed_keys: allowed_keys).merge(context)
28
+ Digest::SHA256.hexdigest(JSON.generate(payload))
29
+ end
30
+
31
+ def self.normalize_payload(params, allowed_keys: [])
32
+ data = params || {}
33
+ data = data.to_h if data.respond_to?(:to_h)
34
+ data = data.deep_symbolize_keys if data.respond_to?(:deep_symbolize_keys)
35
+ allowed = allowed_keys.map(&:to_sym)
36
+ data = data.slice(*allowed) if allowed.any?
37
+ normalize_value(data)
38
+ end
39
+
40
+ def self.normalize_value(value)
41
+ case value
42
+ when Hash
43
+ value.keys.sort.each_with_object({}) do |key, acc|
44
+ acc[key] = normalize_value(value[key])
45
+ end
46
+ when Array
47
+ value.map { |item| normalize_value(item) }.sort_by { |item| item.to_s }
48
+ else
49
+ value
50
+ end
51
+ end
52
+
53
+ def self.build_deps(deps, scope_name: "entity")
54
+ return nil if deps.nil? || deps.empty?
55
+
56
+ deps.map do |namespace, id|
57
+ version = VersionCounter.get(namespace: namespace, scope_name: scope_name, scope_id: id)
58
+ "#{namespace}:#{id}:v#{version}"
59
+ end.sort.join("|")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Muninn
4
+ module Cache
5
+ class VersionCounter
6
+ KEY_PREFIX = "cache:version".freeze
7
+
8
+ def self.get(namespace:, scope_name:, scope_id:)
9
+ Muninn.instrument("version_counter.get", namespace: namespace, scope_name: scope_name, scope_id: scope_id) do
10
+ Muninn.redis.get(build_key(namespace: namespace, scope_name: scope_name, scope_id: scope_id))
11
+ end&.then { |v| v ? v.to_i : 0 }.to_i
12
+ rescue Redis::BaseError => e
13
+ Rails.logger.warn "[Muninn] Redis error in VersionCounter.get: #{e.message}"
14
+ 0
15
+ end
16
+
17
+ def self.bump(namespace:, scope_name:, scope_id:, expires_in: nil)
18
+ Muninn.instrument("version_counter.bump", namespace: namespace, scope_name: scope_name, scope_id: scope_id) do
19
+ key = build_key(namespace: namespace, scope_name: scope_name, scope_id: scope_id)
20
+
21
+ if expires_in
22
+ results = Muninn.redis.pipelined do |pipe|
23
+ pipe.incr(key)
24
+ pipe.expire(key, expires_in)
25
+ end
26
+ results.first
27
+ else
28
+ Muninn.redis.incr(key)
29
+ end
30
+ end
31
+ rescue Redis::BaseError => e
32
+ Rails.logger.warn "[Muninn] Redis error in VersionCounter.bump: #{e.message}"
33
+ nil
34
+ end
35
+
36
+ def self.build_key(namespace:, scope_name:, scope_id:)
37
+ [KEY_PREFIX, scope_name, scope_id, namespace].join(":")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Muninn
4
+ module Cache
5
+ end
6
+ end
7
+
8
+ require_relative "cache/version_counter"
9
+ require_relative "cache/key_builder"
10
+ require_relative "cache/invalidation"
11
+ require_relative "cache/caching"
12
+ require_relative "cache/global_invalidation_job"
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Muninn
4
+ class Configuration
5
+ attr_accessor :redis, :version_ttl, :race_condition_ttl
6
+ attr_writer :current_scope_id, :current_user_id, :all_scope_ids, :default_scope_name
7
+
8
+ def default_scope_name
9
+ @default_scope_name || "default"
10
+ end
11
+
12
+ def current_scope_id
13
+ @current_scope_id&.call
14
+ end
15
+
16
+ def current_user_id
17
+ @current_user_id&.call
18
+ end
19
+
20
+ def all_scope_ids
21
+ @all_scope_ids&.call || []
22
+ end
23
+
24
+ def race_condition_ttl
25
+ @race_condition_ttl || 10
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Muninn
4
+ class Railtie < Rails::Railtie
5
+ initializer "muninn.configure" do |app|
6
+ ActiveSupport.on_load(:active_record) do
7
+ include Muninn::Cache::Invalidation
8
+ end
9
+
10
+ ActiveSupport.on_load(:action_controller) do
11
+ include Muninn::Cache::Caching
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Muninn
4
+ VERSION = "0.1.0"
5
+ end
data/lib/muninn.rb ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+ require "active_support"
5
+ require "active_support/concern"
6
+ require "active_support/notifications"
7
+ require "active_job"
8
+
9
+ module Muninn
10
+ NAMESPACE = "muninn"
11
+
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def redis
22
+ configuration.redis
23
+ end
24
+
25
+ def current_scope_id
26
+ configuration.current_scope_id
27
+ end
28
+
29
+ def current_user_id
30
+ configuration.current_user_id
31
+ end
32
+
33
+ def all_scope_ids
34
+ configuration.all_scope_ids
35
+ end
36
+
37
+ def instrument(event, payload = {})
38
+ ActiveSupport::Notifications.instrument("#{event}.#{NAMESPACE}", payload) do
39
+ yield if block_given?
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ require_relative "muninn/version"
46
+ require_relative "muninn/configuration"
47
+ require_relative "muninn/cache"
48
+ require_relative "muninn/railtie" if defined?(Rails::Railtie)
data/logo.png ADDED
Binary file
metadata ADDED
@@ -0,0 +1,177 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: muninn
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kayky Marcelo
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: redis
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '4.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '6.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '4.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '6.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activesupport
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '6.0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '6.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: activerecord
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '6.0'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '6.0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: actionpack
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '6.0'
67
+ type: :runtime
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '6.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: activejob
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '6.0'
81
+ type: :runtime
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '6.0'
88
+ - !ruby/object:Gem::Dependency
89
+ name: rspec
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '3.12'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '3.12'
102
+ - !ruby/object:Gem::Dependency
103
+ name: pry
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: appraisal
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '0'
130
+ description: Declarative cache invalidation for ActiveRecord models and ActionController
131
+ responses using Redis version counters. No TTL guessing — cache is invalidated automatically
132
+ when data changes.
133
+ email:
134
+ - kaykymarcelo2411@gmail.com
135
+ executables: []
136
+ extensions: []
137
+ extra_rdoc_files: []
138
+ files:
139
+ - CHANGELOG.md
140
+ - LICENSE.txt
141
+ - README.md
142
+ - lib/muninn.rb
143
+ - lib/muninn/cache.rb
144
+ - lib/muninn/cache/caching.rb
145
+ - lib/muninn/cache/global_invalidation_job.rb
146
+ - lib/muninn/cache/invalidation.rb
147
+ - lib/muninn/cache/key_builder.rb
148
+ - lib/muninn/cache/version_counter.rb
149
+ - lib/muninn/configuration.rb
150
+ - lib/muninn/railtie.rb
151
+ - lib/muninn/version.rb
152
+ - logo.png
153
+ homepage: https://github.com/KaykyM2411/muninn
154
+ licenses:
155
+ - MIT
156
+ metadata:
157
+ homepage_uri: https://github.com/KaykyM2411/muninn
158
+ source_code_uri: https://github.com/KaykyM2411/muninn
159
+ changelog_uri: https://github.com/KaykyM2411/muninn/blob/main/CHANGELOG.md
160
+ rdoc_options: []
161
+ require_paths:
162
+ - lib
163
+ required_ruby_version: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: '3.0'
168
+ required_rubygems_version: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ version: '0'
173
+ requirements: []
174
+ rubygems_version: 4.0.6
175
+ specification_version: 4
176
+ summary: Versioned cache invalidation via Redis counters for Rails
177
+ test_files: []