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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +171 -0
- data/lib/muninn/cache/caching.rb +120 -0
- data/lib/muninn/cache/global_invalidation_job.rb +30 -0
- data/lib/muninn/cache/invalidation.rb +122 -0
- data/lib/muninn/cache/key_builder.rb +63 -0
- data/lib/muninn/cache/version_counter.rb +41 -0
- data/lib/muninn/cache.rb +12 -0
- data/lib/muninn/configuration.rb +28 -0
- data/lib/muninn/railtie.rb +15 -0
- data/lib/muninn/version.rb +5 -0
- data/lib/muninn.rb +48 -0
- data/logo.png +0 -0
- metadata +177 -0
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
|
data/lib/muninn/cache.rb
ADDED
|
@@ -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
|
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: []
|