mudis 0.4.4 → 0.5.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 +4 -4
- data/README.md +23 -2
- data/lib/mudis/version.rb +1 -1
- data/lib/mudis.rb +51 -5
- data/lib/mudis_config.rb +5 -1
- data/sig/mudis.rbs +5 -1
- data/sig/mudis_config.rbs +8 -6
- data/spec/guardrails_spec.rb +80 -0
- data/spec/mudis_spec.rb +17 -0
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6207a8171eb9fd9723889d90a5a5e696dafc9a894c29d9b6f99340b7c02ec697
|
4
|
+
data.tar.gz: a18de4b21e8b4116c321393d0ada12da91c06682ff099907be84b6ac78c6f999
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e44da4c093e85012cee92cc61be4062c48fe94fe46b22210c1433e47b8eef91755359a658f936075ee514a5adcbf5ea2dae184031042532d13907d619ab77876
|
7
|
+
data.tar.gz: 79c1703d2d03938d801d752c7cc3c7c98ed777933e85b091ece31180d1dd2b9eaf0af8e8b8bd30221750cd89aa5cfb2989e8914aa4ef09fe6d1316a3ee678024
|
data/README.md
CHANGED
@@ -176,6 +176,20 @@ Mudis.metrics # => { hits: 0, misses: 0, ... }
|
|
176
176
|
Mudis.read("key") # => "value" (still cached)
|
177
177
|
```
|
178
178
|
|
179
|
+
#### `Mudis.least_touched`
|
180
|
+
|
181
|
+
Returns the top `n` (or all) keys that have been read the fewest number of times, across all buckets. This is useful for identifying low-value cache entries that may be safe to remove or exclude from caching altogether.
|
182
|
+
|
183
|
+
Each result includes the full key and its access count.
|
184
|
+
|
185
|
+
```ruby
|
186
|
+
Mudis.least_touched
|
187
|
+
# => [["foo", 0], ["user:42", 1], ["product:123", 2], ...]
|
188
|
+
|
189
|
+
Mudis.least_touched(5)
|
190
|
+
# => returns top 5 least accessed keys
|
191
|
+
```
|
192
|
+
|
179
193
|
---
|
180
194
|
|
181
195
|
## Rails Service Integration
|
@@ -277,6 +291,11 @@ Mudis.metrics
|
|
277
291
|
# evictions: 3,
|
278
292
|
# rejected: 0,
|
279
293
|
# total_memory: 45678,
|
294
|
+
# least_touched: [
|
295
|
+
# ["user:1", 0],
|
296
|
+
# ["post:5", 1],
|
297
|
+
# ...
|
298
|
+
# ],
|
280
299
|
# buckets: [
|
281
300
|
# { index: 0, keys: 12, memory_bytes: 12345, lru_size: 12 },
|
282
301
|
# ...
|
@@ -309,6 +328,8 @@ end
|
|
309
328
|
| `Mudis.start_expiry_thread` | Background TTL cleanup loop (every N sec) | Disabled by default|
|
310
329
|
| `Mudis.hard_memory_limit` | Enforce hard memory limits on key size and reject if exceeded | `false`|
|
311
330
|
| `Mudis.max_bytes` | Maximum allowed cache size | `1GB`|
|
331
|
+
| `Mudis.max_ttl` | Set the maximum permitted TTL | `nil` (no limit) |
|
332
|
+
| `Mudis.default_ttl` | Set the default TTL for fallback when none is provided | `nil` |
|
312
333
|
|
313
334
|
Buckets can also be set using a `MUDIS_BUCKETS` environment variable.
|
314
335
|
|
@@ -519,8 +540,8 @@ Mudis is not intended to be a general-purpose, distributed caching platform. You
|
|
519
540
|
|
520
541
|
#### Safety & Policy Controls
|
521
542
|
|
522
|
-
- [
|
523
|
-
- [
|
543
|
+
- [x] max_ttl: Enforce a global upper bound on expires_in to prevent excessively long-lived keys
|
544
|
+
- [x] default_ttl: Provide a fallback TTL when one is not specified
|
524
545
|
|
525
546
|
#### Debugging
|
526
547
|
|
data/lib/mudis/version.rb
CHANGED
data/lib/mudis.rb
CHANGED
@@ -17,9 +17,11 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
17
17
|
@metrics_mutex = Mutex.new # Mutex for synchronizing access to metrics
|
18
18
|
@max_value_bytes = nil # Optional size cap per value
|
19
19
|
@stop_expiry = false # Signal for stopping expiry thread
|
20
|
+
@max_ttl = nil # Optional maximum TTL for cache entries
|
21
|
+
@default_ttl = nil # Default TTL for cache entries if not specified
|
20
22
|
|
21
23
|
class << self
|
22
|
-
attr_accessor :serializer, :compress, :hard_memory_limit
|
24
|
+
attr_accessor :serializer, :compress, :hard_memory_limit, :max_ttl, :default_ttl
|
23
25
|
attr_reader :max_bytes, :max_value_bytes
|
24
26
|
|
25
27
|
# Configures Mudis with a block, allowing customization of settings
|
@@ -34,7 +36,7 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
34
36
|
end
|
35
37
|
|
36
38
|
# Applies the current configuration to Mudis
|
37
|
-
def apply_config!
|
39
|
+
def apply_config! # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
38
40
|
validate_config!
|
39
41
|
|
40
42
|
self.serializer = config.serializer
|
@@ -42,10 +44,17 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
42
44
|
self.max_value_bytes = config.max_value_bytes
|
43
45
|
self.hard_memory_limit = config.hard_memory_limit
|
44
46
|
self.max_bytes = config.max_bytes
|
47
|
+
self.max_ttl = config.max_ttl
|
48
|
+
self.default_ttl = config.default_ttl
|
49
|
+
|
50
|
+
if config.buckets # rubocop:disable Style/GuardClause
|
51
|
+
@buckets = config.buckets
|
52
|
+
reset!
|
53
|
+
end
|
45
54
|
end
|
46
55
|
|
47
56
|
# Validates the current configuration, raising errors for invalid settings
|
48
|
-
def validate_config! # rubocop:disable Metrics/AbcSize
|
57
|
+
def validate_config! # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
49
58
|
if config.max_value_bytes && config.max_value_bytes > config.max_bytes
|
50
59
|
raise ArgumentError,
|
51
60
|
"max_value_bytes cannot exceed max_bytes"
|
@@ -54,6 +63,8 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
54
63
|
raise ArgumentError, "max_value_bytes must be > 0" if config.max_value_bytes && config.max_value_bytes <= 0
|
55
64
|
|
56
65
|
raise ArgumentError, "buckets must be > 0" if config.buckets && config.buckets <= 0
|
66
|
+
raise ArgumentError, "max_ttl must be > 0" if config.max_ttl && config.max_ttl <= 0
|
67
|
+
raise ArgumentError, "default_ttl must be > 0" if config.default_ttl && config.default_ttl <= 0
|
57
68
|
end
|
58
69
|
|
59
70
|
# Returns a snapshot of metrics (thread-safe)
|
@@ -65,6 +76,7 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
65
76
|
evictions: @metrics[:evictions],
|
66
77
|
rejected: @metrics[:rejected],
|
67
78
|
total_memory: current_memory_bytes,
|
79
|
+
least_touched: least_touched(10),
|
68
80
|
buckets: buckets.times.map do |idx|
|
69
81
|
{
|
70
82
|
index: idx,
|
@@ -186,11 +198,12 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
186
198
|
end
|
187
199
|
|
188
200
|
# Reads and returns the value for a key, updating LRU and metrics
|
189
|
-
def read(key, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
201
|
+
def read(key, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
190
202
|
key = namespaced_key(key, namespace)
|
191
203
|
raw_entry = nil
|
192
204
|
idx = bucket_index(key)
|
193
205
|
mutex = @mutexes[idx]
|
206
|
+
store = @stores[idx]
|
194
207
|
|
195
208
|
mutex.synchronize do
|
196
209
|
raw_entry = @stores[idx][key]
|
@@ -199,6 +212,8 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
199
212
|
raw_entry = nil
|
200
213
|
end
|
201
214
|
|
215
|
+
store[key][:touches] = (store[key][:touches] || 0) + 1 if store[key]
|
216
|
+
|
202
217
|
metric(:hits) if raw_entry
|
203
218
|
metric(:misses) unless raw_entry
|
204
219
|
end
|
@@ -223,6 +238,9 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
223
238
|
return
|
224
239
|
end
|
225
240
|
|
241
|
+
# Ensure expires_in respects max_ttl and default_ttl
|
242
|
+
expires_in = effective_ttl(expires_in)
|
243
|
+
|
226
244
|
idx = bucket_index(key)
|
227
245
|
mutex = @mutexes[idx]
|
228
246
|
store = @stores[idx]
|
@@ -238,7 +256,8 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
238
256
|
store[key] = {
|
239
257
|
value: raw,
|
240
258
|
expires_at: expires_in ? Time.now + expires_in : nil,
|
241
|
-
created_at: Time.now
|
259
|
+
created_at: Time.now,
|
260
|
+
touches: 0
|
242
261
|
}
|
243
262
|
|
244
263
|
insert_lru(idx, key)
|
@@ -364,6 +383,24 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
364
383
|
keys
|
365
384
|
end
|
366
385
|
|
386
|
+
# Returns the least-touched keys across all buckets
|
387
|
+
def least_touched(n = 10) # rubocop:disable Metrics/MethodLength,Naming/MethodParameterName
|
388
|
+
keys_with_touches = []
|
389
|
+
|
390
|
+
buckets.times do |idx|
|
391
|
+
mutex = @mutexes[idx]
|
392
|
+
store = @stores[idx]
|
393
|
+
|
394
|
+
mutex.synchronize do
|
395
|
+
store.each do |key, entry|
|
396
|
+
keys_with_touches << [key, entry[:touches] || 0]
|
397
|
+
end
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
keys_with_touches.sort_by { |_, count| count }.first(n)
|
402
|
+
end
|
403
|
+
|
367
404
|
# Returns total memory used across all buckets
|
368
405
|
def current_memory_bytes
|
369
406
|
@current_bytes.sum
|
@@ -447,5 +484,14 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
447
484
|
ns = namespace || Thread.current[:mudis_namespace]
|
448
485
|
ns ? "#{ns}:#{key}" : key
|
449
486
|
end
|
487
|
+
|
488
|
+
# Calculates the effective TTL for an entry, respecting max_ttl if set
|
489
|
+
def effective_ttl(expires_in)
|
490
|
+
ttl = expires_in || @default_ttl
|
491
|
+
return nil unless ttl
|
492
|
+
return ttl unless @max_ttl
|
493
|
+
|
494
|
+
[ttl, @max_ttl].min
|
495
|
+
end
|
450
496
|
end
|
451
497
|
end
|
data/lib/mudis_config.rb
CHANGED
@@ -8,7 +8,9 @@ class MudisConfig
|
|
8
8
|
:max_value_bytes,
|
9
9
|
:hard_memory_limit,
|
10
10
|
:max_bytes,
|
11
|
-
:buckets
|
11
|
+
:buckets,
|
12
|
+
:max_ttl,
|
13
|
+
:default_ttl
|
12
14
|
|
13
15
|
def initialize
|
14
16
|
@serializer = JSON # Default serialization strategy
|
@@ -17,5 +19,7 @@ class MudisConfig
|
|
17
19
|
@hard_memory_limit = false # Enforce max_bytes as hard cap
|
18
20
|
@max_bytes = 1_073_741_824 # 1 GB default max cache size
|
19
21
|
@buckets = nil # use nil to signal fallback to ENV or default
|
22
|
+
@max_ttl = nil # Max TTL for cache entries (optional)
|
23
|
+
@default_ttl = nil # Default TTL for cache entries (optional)
|
20
24
|
end
|
21
25
|
end
|
data/sig/mudis.rbs
CHANGED
@@ -6,10 +6,13 @@ class Mudis
|
|
6
6
|
attr_accessor hard_memory_limit : bool
|
7
7
|
attr_reader max_bytes : Integer
|
8
8
|
attr_reader max_value_bytes : Integer?
|
9
|
-
|
9
|
+
attr_accessor max_ttl: Integer?
|
10
|
+
attr_accessor default_ttl: Integer?
|
11
|
+
|
10
12
|
def configure: () { (config: MudisConfig) -> void } -> void
|
11
13
|
def config: () -> MudisConfig
|
12
14
|
def apply_config!: () -> void
|
15
|
+
def validate_config!: () -> void
|
13
16
|
|
14
17
|
def buckets: () -> Integer
|
15
18
|
end
|
@@ -43,6 +46,7 @@ class Mudis
|
|
43
46
|
def self.all_keys: () -> Array[String]
|
44
47
|
def self.current_memory_bytes: () -> Integer
|
45
48
|
def self.max_memory_bytes: () -> Integer
|
49
|
+
def self.least_touched: (?Integer) -> Array[[String, Integer]]
|
46
50
|
|
47
51
|
# State reset
|
48
52
|
def self.reset!: () -> void
|
data/sig/mudis_config.rbs
CHANGED
@@ -1,8 +1,10 @@
|
|
1
1
|
class MudisConfig
|
2
|
-
attr_accessor serializer
|
3
|
-
attr_accessor compress
|
4
|
-
attr_accessor max_value_bytes
|
5
|
-
attr_accessor hard_memory_limit
|
6
|
-
attr_accessor max_bytes
|
7
|
-
attr_accessor
|
2
|
+
attr_accessor serializer: Object
|
3
|
+
attr_accessor compress: bool
|
4
|
+
attr_accessor max_value_bytes: Integer?
|
5
|
+
attr_accessor hard_memory_limit: bool
|
6
|
+
attr_accessor max_bytes: Integer
|
7
|
+
attr_accessor max_ttl: Integer?
|
8
|
+
attr_accessor default_ttl: Integer?
|
9
|
+
attr_accessor buckets: Integer?
|
8
10
|
end
|
data/spec/guardrails_spec.rb
CHANGED
@@ -3,6 +3,86 @@
|
|
3
3
|
require "spec_helper"
|
4
4
|
require "climate_control"
|
5
5
|
|
6
|
+
RSpec.describe "Mudis TTL Guardrail" do # rubocop:disable Metrics/BlockLength
|
7
|
+
before do
|
8
|
+
Mudis.reset!
|
9
|
+
Mudis.configure do |c|
|
10
|
+
c.max_ttl = 60 # 60 seconds max
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
describe "default_ttl configuration" do # rubocop:disable Metrics/BlockLength
|
15
|
+
before do
|
16
|
+
Mudis.reset!
|
17
|
+
Mudis.configure do |c|
|
18
|
+
c.default_ttl = 60
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
it "applies default_ttl when expires_in is nil" do
|
23
|
+
Mudis.write("foo", "bar") # no explicit expires_in
|
24
|
+
meta = Mudis.inspect("foo")
|
25
|
+
expect(meta[:expires_at]).not_to be_nil
|
26
|
+
expect(meta[:expires_at]).to be_within(5).of(Time.now + 60)
|
27
|
+
end
|
28
|
+
|
29
|
+
it "respects expires_in if explicitly given" do
|
30
|
+
Mudis.write("short_lived", "bar", expires_in: 10)
|
31
|
+
meta = Mudis.inspect("short_lived")
|
32
|
+
expect(meta[:expires_at]).not_to be_nil
|
33
|
+
expect(meta[:expires_at]).to be_within(5).of(Time.now + 10)
|
34
|
+
end
|
35
|
+
|
36
|
+
it "applies max_ttl over default_ttl if both are set" do
|
37
|
+
Mudis.configure do |c|
|
38
|
+
c.default_ttl = 120
|
39
|
+
c.max_ttl = 30
|
40
|
+
end
|
41
|
+
|
42
|
+
Mudis.write("capped", "baz") # no explicit expires_in
|
43
|
+
meta = Mudis.inspect("capped")
|
44
|
+
expect(meta[:expires_at]).not_to be_nil
|
45
|
+
expect(meta[:expires_at]).to be_within(5).of(Time.now + 30)
|
46
|
+
end
|
47
|
+
|
48
|
+
it "stores forever if default_ttl and expires_in are nil" do
|
49
|
+
Mudis.configure do |c|
|
50
|
+
c.default_ttl = nil
|
51
|
+
end
|
52
|
+
|
53
|
+
Mudis.write("forever", "ever")
|
54
|
+
meta = Mudis.inspect("forever")
|
55
|
+
expect(meta[:expires_at]).to be_nil
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
it "clamps expires_in to max_ttl if it exceeds max_ttl" do
|
60
|
+
Mudis.write("foo", "bar", expires_in: 300) # user requests 5 minutes
|
61
|
+
|
62
|
+
metadata = Mudis.inspect("foo")
|
63
|
+
ttl = metadata[:expires_at] - metadata[:created_at]
|
64
|
+
|
65
|
+
expect(ttl).to be <= 60
|
66
|
+
expect(ttl).to be > 0
|
67
|
+
end
|
68
|
+
|
69
|
+
it "respects expires_in if below max_ttl" do
|
70
|
+
Mudis.write("bar", "baz", expires_in: 30) # under the max_ttl
|
71
|
+
|
72
|
+
metadata = Mudis.inspect("bar")
|
73
|
+
ttl = metadata[:expires_at] - metadata[:created_at]
|
74
|
+
|
75
|
+
expect(ttl).to be_within(1).of(30)
|
76
|
+
end
|
77
|
+
|
78
|
+
it "allows nil expires_in (no expiry) if not required" do
|
79
|
+
Mudis.write("baz", "no expiry")
|
80
|
+
|
81
|
+
metadata = Mudis.inspect("baz")
|
82
|
+
expect(metadata[:expires_at]).to be_nil
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
6
86
|
RSpec.describe "Mudis Configuration Guardrails" do # rubocop:disable Metrics/BlockLength
|
7
87
|
after { Mudis.reset! }
|
8
88
|
|
data/spec/mudis_spec.rb
CHANGED
@@ -239,6 +239,23 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
239
239
|
end
|
240
240
|
end
|
241
241
|
|
242
|
+
describe ".least_touched" do
|
243
|
+
it "returns keys with lowest read access counts" do
|
244
|
+
Mudis.reset!
|
245
|
+
Mudis.write("a", 1)
|
246
|
+
Mudis.write("b", 2)
|
247
|
+
Mudis.write("c", 3)
|
248
|
+
|
249
|
+
Mudis.read("a")
|
250
|
+
Mudis.read("a")
|
251
|
+
Mudis.read("b")
|
252
|
+
|
253
|
+
least = Mudis.least_touched(2)
|
254
|
+
expect(least.map(&:first)).to include("c") # Never read
|
255
|
+
expect(least.first.last).to eq(0)
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
242
259
|
describe ".configure" do
|
243
260
|
it "applies configuration settings correctly" do
|
244
261
|
Mudis.configure do |c|
|