mudis 0.4.4 → 0.6.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 +47 -4
- data/lib/mudis/version.rb +1 -1
- data/lib/mudis.rb +75 -5
- data/lib/mudis_config.rb +5 -1
- data/sig/mudis.rbs +7 -1
- data/sig/mudis_config.rbs +8 -6
- data/spec/eviction_spec.rb +29 -0
- data/spec/guardrails_spec.rb +80 -0
- data/spec/memory_guard_spec.rb +33 -0
- data/spec/metrics_spec.rb +34 -0
- data/spec/mudis_spec.rb +18 -132
- data/spec/namespace_spec.rb +69 -0
- data/spec/reset_spec.rb +31 -0
- metadata +11 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: aeea820874569fe79af99d53f2a3ae3a7a9b74dc8a2d9dccbfbaa3f4ebab3967
|
4
|
+
data.tar.gz: 211d8c786790478e861461aab7444bd4bd1342afa07039ef3608f66dbd401cd6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b93779c0990a328a3d24f4741b9934833e5524e099e2ddcdd2ae38f297e599f2cd49bd4fbc43d3cd1d1f81d2655220760b2573138ade72b289b785fc718c9bcd
|
7
|
+
data.tar.gz: bf25460f6237402793327d4bb04aef416d39f240e80350b8428433aa4245036cac0582f75e25b79c6902d21751403be9def2df01b9ebe54536ee39d229571590
|
data/README.md
CHANGED
@@ -176,6 +176,42 @@ 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
|
+
|
193
|
+
#### `Mudis.keys(namespace:)`
|
194
|
+
|
195
|
+
Returns all keys for a given namespace.
|
196
|
+
|
197
|
+
```ruby
|
198
|
+
Mudis.write("u1", "alpha", namespace: "users")
|
199
|
+
Mudis.write("u2", "beta", namespace: "users")
|
200
|
+
|
201
|
+
Mudis.keys(namespace: "users")
|
202
|
+
# => ["u1", "u2"]
|
203
|
+
|
204
|
+
```
|
205
|
+
|
206
|
+
#### `Mudis.clear_namespace(namespace:)`
|
207
|
+
|
208
|
+
Deletes all keys within a namespace.
|
209
|
+
|
210
|
+
```ruby
|
211
|
+
Mudis.clear_namespace("users")
|
212
|
+
Mudis.read("u1", namespace: "users") # => nil
|
213
|
+
```
|
214
|
+
|
179
215
|
---
|
180
216
|
|
181
217
|
## Rails Service Integration
|
@@ -277,6 +313,11 @@ Mudis.metrics
|
|
277
313
|
# evictions: 3,
|
278
314
|
# rejected: 0,
|
279
315
|
# total_memory: 45678,
|
316
|
+
# least_touched: [
|
317
|
+
# ["user:1", 0],
|
318
|
+
# ["post:5", 1],
|
319
|
+
# ...
|
320
|
+
# ],
|
280
321
|
# buckets: [
|
281
322
|
# { index: 0, keys: 12, memory_bytes: 12345, lru_size: 12 },
|
282
323
|
# ...
|
@@ -309,6 +350,8 @@ end
|
|
309
350
|
| `Mudis.start_expiry_thread` | Background TTL cleanup loop (every N sec) | Disabled by default|
|
310
351
|
| `Mudis.hard_memory_limit` | Enforce hard memory limits on key size and reject if exceeded | `false`|
|
311
352
|
| `Mudis.max_bytes` | Maximum allowed cache size | `1GB`|
|
353
|
+
| `Mudis.max_ttl` | Set the maximum permitted TTL | `nil` (no limit) |
|
354
|
+
| `Mudis.default_ttl` | Set the default TTL for fallback when none is provided | `nil` |
|
312
355
|
|
313
356
|
Buckets can also be set using a `MUDIS_BUCKETS` environment variable.
|
314
357
|
|
@@ -515,16 +558,16 @@ Mudis is not intended to be a general-purpose, distributed caching platform. You
|
|
515
558
|
|
516
559
|
#### API Enhancements
|
517
560
|
|
518
|
-
- [
|
561
|
+
- [x] bulk_read(keys, namespace:): Batch retrieval of multiple keys with a single method call
|
519
562
|
|
520
563
|
#### Safety & Policy Controls
|
521
564
|
|
522
|
-
- [
|
523
|
-
- [
|
565
|
+
- [x] max_ttl: Enforce a global upper bound on expires_in to prevent excessively long-lived keys
|
566
|
+
- [x] default_ttl: Provide a fallback TTL when one is not specified
|
524
567
|
|
525
568
|
#### Debugging
|
526
569
|
|
527
|
-
- [
|
570
|
+
- [x] clear_namespace(namespace): Remove all keys in a namespace in one call
|
528
571
|
|
529
572
|
---
|
530
573
|
|
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,48 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
364
383
|
keys
|
365
384
|
end
|
366
385
|
|
386
|
+
# Returns all keys in a specific namespace
|
387
|
+
def keys(namespace:)
|
388
|
+
raise ArgumentError, "namespace is required" unless namespace
|
389
|
+
|
390
|
+
prefix = "#{namespace}:"
|
391
|
+
all_keys.select { |key| key.start_with?(prefix) }.map { |key| key.delete_prefix(prefix) }
|
392
|
+
end
|
393
|
+
|
394
|
+
# Clears all keys in a specific namespace
|
395
|
+
def clear_namespace(namespace:)
|
396
|
+
raise ArgumentError, "namespace is required" unless namespace
|
397
|
+
|
398
|
+
prefix = "#{namespace}:"
|
399
|
+
buckets.times do |idx|
|
400
|
+
mutex = @mutexes[idx]
|
401
|
+
store = @stores[idx]
|
402
|
+
|
403
|
+
mutex.synchronize do
|
404
|
+
keys_to_delete = store.keys.select { |key| key.start_with?(prefix) }
|
405
|
+
keys_to_delete.each { |key| evict_key(idx, key) }
|
406
|
+
end
|
407
|
+
end
|
408
|
+
end
|
409
|
+
|
410
|
+
# Returns the least-touched keys across all buckets
|
411
|
+
def least_touched(n = 10) # rubocop:disable Metrics/MethodLength,Naming/MethodParameterName
|
412
|
+
keys_with_touches = []
|
413
|
+
|
414
|
+
buckets.times do |idx|
|
415
|
+
mutex = @mutexes[idx]
|
416
|
+
store = @stores[idx]
|
417
|
+
|
418
|
+
mutex.synchronize do
|
419
|
+
store.each do |key, entry|
|
420
|
+
keys_with_touches << [key, entry[:touches] || 0]
|
421
|
+
end
|
422
|
+
end
|
423
|
+
end
|
424
|
+
|
425
|
+
keys_with_touches.sort_by { |_, count| count }.first(n)
|
426
|
+
end
|
427
|
+
|
367
428
|
# Returns total memory used across all buckets
|
368
429
|
def current_memory_bytes
|
369
430
|
@current_bytes.sum
|
@@ -447,5 +508,14 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
447
508
|
ns = namespace || Thread.current[:mudis_namespace]
|
448
509
|
ns ? "#{ns}:#{key}" : key
|
449
510
|
end
|
511
|
+
|
512
|
+
# Calculates the effective TTL for an entry, respecting max_ttl if set
|
513
|
+
def effective_ttl(expires_in)
|
514
|
+
ttl = expires_in || @default_ttl
|
515
|
+
return nil unless ttl
|
516
|
+
return ttl unless @max_ttl
|
517
|
+
|
518
|
+
[ttl, @max_ttl].min
|
519
|
+
end
|
450
520
|
end
|
451
521
|
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
|
@@ -36,6 +39,8 @@ class Mudis
|
|
36
39
|
def self.clear: (String, ?namespace: String) -> void
|
37
40
|
def self.replace: (String, untyped, ?expires_in: Integer, ?namespace: String) -> void
|
38
41
|
def self.inspect: (String, ?namespace: String) -> Hash[Symbol, untyped]?
|
42
|
+
def self.keys: (?namespace: String) -> Array[String]
|
43
|
+
def self.clear_namespace: (?namespace: String) -> void
|
39
44
|
|
40
45
|
# Introspection & management
|
41
46
|
def self.metrics: () -> Hash[Symbol, untyped]
|
@@ -43,6 +48,7 @@ class Mudis
|
|
43
48
|
def self.all_keys: () -> Array[String]
|
44
49
|
def self.current_memory_bytes: () -> Integer
|
45
50
|
def self.max_memory_bytes: () -> Integer
|
51
|
+
def self.least_touched: (?Integer) -> Array[[String, Integer]]
|
46
52
|
|
47
53
|
# State reset
|
48
54
|
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
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe "Mudis LRU Eviction" do
|
6
|
+
before do
|
7
|
+
Mudis.reset!
|
8
|
+
Mudis.stop_expiry_thread
|
9
|
+
|
10
|
+
Mudis.instance_variable_set(:@buckets, 1)
|
11
|
+
Mudis.instance_variable_set(:@stores, [{}])
|
12
|
+
Mudis.instance_variable_set(:@mutexes, [Mutex.new])
|
13
|
+
Mudis.instance_variable_set(:@lru_heads, [nil])
|
14
|
+
Mudis.instance_variable_set(:@lru_tails, [nil])
|
15
|
+
Mudis.instance_variable_set(:@lru_nodes, [{}])
|
16
|
+
Mudis.instance_variable_set(:@current_bytes, [0])
|
17
|
+
Mudis.hard_memory_limit = false
|
18
|
+
Mudis.instance_variable_set(:@threshold_bytes, 60)
|
19
|
+
Mudis.max_value_bytes = 100
|
20
|
+
end
|
21
|
+
|
22
|
+
it "evicts old entries when size limit is reached" do
|
23
|
+
Mudis.write("a", "a" * 50)
|
24
|
+
Mudis.write("b", "b" * 50)
|
25
|
+
|
26
|
+
expect(Mudis.read("a")).to be_nil
|
27
|
+
expect(Mudis.read("b")).not_to be_nil
|
28
|
+
end
|
29
|
+
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
|
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe "Mudis Memory Guardrails" do
|
6
|
+
before do
|
7
|
+
Mudis.reset!
|
8
|
+
Mudis.stop_expiry_thread
|
9
|
+
Mudis.instance_variable_set(:@buckets, 1)
|
10
|
+
Mudis.instance_variable_set(:@stores, [{}])
|
11
|
+
Mudis.instance_variable_set(:@mutexes, [Mutex.new])
|
12
|
+
Mudis.instance_variable_set(:@lru_heads, [nil])
|
13
|
+
Mudis.instance_variable_set(:@lru_tails, [nil])
|
14
|
+
Mudis.instance_variable_set(:@lru_nodes, [{}])
|
15
|
+
Mudis.instance_variable_set(:@current_bytes, [0])
|
16
|
+
|
17
|
+
Mudis.max_value_bytes = nil
|
18
|
+
Mudis.instance_variable_set(:@threshold_bytes, 1_000_000)
|
19
|
+
Mudis.hard_memory_limit = true
|
20
|
+
Mudis.instance_variable_set(:@max_bytes, 100)
|
21
|
+
end
|
22
|
+
|
23
|
+
it "rejects writes that exceed max memory" do
|
24
|
+
big_value = "a" * 90
|
25
|
+
Mudis.write("a", big_value)
|
26
|
+
expect(Mudis.read("a")).to eq(big_value)
|
27
|
+
|
28
|
+
big_value2 = "b" * 90
|
29
|
+
Mudis.write("b", big_value2)
|
30
|
+
expect(Mudis.read("b")).to be_nil
|
31
|
+
expect(Mudis.metrics[:rejected]).to be > 0
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe "Mudis Metrics" do # rubocop:disable Metrics/BlockLength
|
6
|
+
it "tracks hits and misses" do
|
7
|
+
Mudis.write("hit_me", "value")
|
8
|
+
Mudis.read("hit_me")
|
9
|
+
Mudis.read("miss_me")
|
10
|
+
metrics = Mudis.metrics
|
11
|
+
expect(metrics[:hits]).to eq(1)
|
12
|
+
expect(metrics[:misses]).to eq(1)
|
13
|
+
end
|
14
|
+
|
15
|
+
it "includes per-bucket stats" do
|
16
|
+
Mudis.write("a", "x" * 50)
|
17
|
+
metrics = Mudis.metrics
|
18
|
+
expect(metrics).to include(:buckets)
|
19
|
+
expect(metrics[:buckets]).to be_an(Array)
|
20
|
+
expect(metrics[:buckets].first).to include(:index, :keys, :memory_bytes, :lru_size)
|
21
|
+
end
|
22
|
+
|
23
|
+
it "resets only the metrics without clearing cache" do
|
24
|
+
Mudis.write("metrics_key", "value")
|
25
|
+
Mudis.read("metrics_key")
|
26
|
+
Mudis.read("missing_key")
|
27
|
+
expect(Mudis.metrics[:hits]).to eq(1)
|
28
|
+
expect(Mudis.metrics[:misses]).to eq(1)
|
29
|
+
Mudis.reset_metrics!
|
30
|
+
expect(Mudis.metrics[:hits]).to eq(0)
|
31
|
+
expect(Mudis.metrics[:misses]).to eq(0)
|
32
|
+
expect(Mudis.read("metrics_key")).to eq("value")
|
33
|
+
end
|
34
|
+
end
|
data/spec/mudis_spec.rb
CHANGED
@@ -114,24 +114,6 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
114
114
|
end
|
115
115
|
end
|
116
116
|
|
117
|
-
describe "namespacing" do
|
118
|
-
it "uses thread-local namespace in block" do
|
119
|
-
Mudis.with_namespace("test") do
|
120
|
-
Mudis.write("foo", "bar")
|
121
|
-
end
|
122
|
-
expect(Mudis.read("foo", namespace: "test")).to eq("bar")
|
123
|
-
expect(Mudis.read("foo")).to be_nil
|
124
|
-
end
|
125
|
-
|
126
|
-
it "supports explicit namespace override" do
|
127
|
-
Mudis.write("x", 1, namespace: "alpha")
|
128
|
-
Mudis.write("x", 2, namespace: "beta")
|
129
|
-
expect(Mudis.read("x", namespace: "alpha")).to eq(1)
|
130
|
-
expect(Mudis.read("x", namespace: "beta")).to eq(2)
|
131
|
-
expect(Mudis.read("x")).to be_nil
|
132
|
-
end
|
133
|
-
end
|
134
|
-
|
135
117
|
describe "expiry handling" do
|
136
118
|
it "expires values after specified time" do
|
137
119
|
Mudis.write("short_lived", "gone soon", expires_in: 1)
|
@@ -140,74 +122,6 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
140
122
|
end
|
141
123
|
end
|
142
124
|
|
143
|
-
describe ".metrics" do
|
144
|
-
it "tracks hits and misses" do
|
145
|
-
Mudis.write("hit_me", "value")
|
146
|
-
Mudis.read("hit_me") # hit
|
147
|
-
Mudis.read("miss_me") # miss
|
148
|
-
metrics = Mudis.metrics
|
149
|
-
expect(metrics[:hits]).to eq(1)
|
150
|
-
expect(metrics[:misses]).to eq(1)
|
151
|
-
end
|
152
|
-
end
|
153
|
-
|
154
|
-
describe "memory guards" do
|
155
|
-
before do
|
156
|
-
Mudis.stop_expiry_thread
|
157
|
-
Mudis.instance_variable_set(:@buckets, 1)
|
158
|
-
Mudis.instance_variable_set(:@stores, [{}])
|
159
|
-
Mudis.instance_variable_set(:@mutexes, [Mutex.new])
|
160
|
-
Mudis.instance_variable_set(:@lru_heads, [nil])
|
161
|
-
Mudis.instance_variable_set(:@lru_tails, [nil])
|
162
|
-
Mudis.instance_variable_set(:@lru_nodes, [{}])
|
163
|
-
Mudis.instance_variable_set(:@current_bytes, [0])
|
164
|
-
|
165
|
-
Mudis.max_value_bytes = nil
|
166
|
-
Mudis.instance_variable_set(:@threshold_bytes, 1_000_000) # optional
|
167
|
-
Mudis.hard_memory_limit = true
|
168
|
-
Mudis.instance_variable_set(:@max_bytes, 100) # artificially low
|
169
|
-
end
|
170
|
-
|
171
|
-
it "rejects writes that exceed max memory" do
|
172
|
-
big_value = "a" * 90
|
173
|
-
Mudis.write("a", big_value)
|
174
|
-
expect(Mudis.read("a")).to eq(big_value)
|
175
|
-
|
176
|
-
big_value_2 = "b" * 90 # rubocop:disable Naming/VariableNumber
|
177
|
-
Mudis.write("b", big_value_2)
|
178
|
-
expect(Mudis.read("b")).to be_nil
|
179
|
-
expect(Mudis.metrics[:rejected]).to be > 0
|
180
|
-
end
|
181
|
-
end
|
182
|
-
|
183
|
-
describe "LRU eviction" do
|
184
|
-
it "evicts old entries when size limit is reached" do
|
185
|
-
Mudis.stop_expiry_thread
|
186
|
-
|
187
|
-
# Force one bucket
|
188
|
-
Mudis.instance_variable_set(:@buckets, 1)
|
189
|
-
Mudis.instance_variable_set(:@stores, [{}])
|
190
|
-
Mudis.instance_variable_set(:@mutexes, [Mutex.new])
|
191
|
-
Mudis.instance_variable_set(:@lru_heads, [nil])
|
192
|
-
Mudis.instance_variable_set(:@lru_tails, [nil])
|
193
|
-
Mudis.instance_variable_set(:@lru_nodes, [{}])
|
194
|
-
Mudis.instance_variable_set(:@current_bytes, [0])
|
195
|
-
Mudis.hard_memory_limit = false
|
196
|
-
# Set very small threshold
|
197
|
-
Mudis.instance_variable_set(:@threshold_bytes, 60)
|
198
|
-
Mudis.max_value_bytes = 100
|
199
|
-
|
200
|
-
big_val1 = "a" * 50
|
201
|
-
big_val2 = "b" * 50
|
202
|
-
|
203
|
-
Mudis.write("a", big_val1)
|
204
|
-
Mudis.write("b", big_val2)
|
205
|
-
|
206
|
-
expect(Mudis.read("a")).to be_nil
|
207
|
-
expect(Mudis.read("b")).not_to be_nil
|
208
|
-
end
|
209
|
-
end
|
210
|
-
|
211
125
|
describe ".all_keys" do
|
212
126
|
it "lists all stored keys" do
|
213
127
|
Mudis.write("k1", 1)
|
@@ -223,19 +137,30 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
223
137
|
|
224
138
|
describe ".current_memory_bytes" do
|
225
139
|
it "returns a non-zero byte count after writes" do
|
140
|
+
Mudis.configure do |c|
|
141
|
+
c.max_value_bytes = nil
|
142
|
+
c.hard_memory_limit = false
|
143
|
+
end
|
144
|
+
|
226
145
|
Mudis.write("size_test", "a" * 100)
|
227
146
|
expect(Mudis.current_memory_bytes).to be > 0
|
228
147
|
end
|
229
148
|
end
|
230
149
|
|
231
|
-
describe ".
|
232
|
-
it "
|
233
|
-
Mudis.
|
234
|
-
|
150
|
+
describe ".least_touched" do
|
151
|
+
it "returns keys with lowest read access counts" do
|
152
|
+
Mudis.reset!
|
153
|
+
Mudis.write("a", 1)
|
154
|
+
Mudis.write("b", 2)
|
155
|
+
Mudis.write("c", 3)
|
235
156
|
|
236
|
-
|
237
|
-
|
238
|
-
|
157
|
+
Mudis.read("a")
|
158
|
+
Mudis.read("a")
|
159
|
+
Mudis.read("b")
|
160
|
+
|
161
|
+
least = Mudis.least_touched(2)
|
162
|
+
expect(least.map(&:first)).to include("c") # Never read
|
163
|
+
expect(least.first.last).to eq(0)
|
239
164
|
end
|
240
165
|
end
|
241
166
|
|
@@ -255,43 +180,4 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
255
180
|
expect(Mudis.max_bytes).to eq(987_654)
|
256
181
|
end
|
257
182
|
end
|
258
|
-
|
259
|
-
describe ".reset!" do
|
260
|
-
it "clears all stores, memory, and metrics" do
|
261
|
-
Mudis.write("reset_key", "value")
|
262
|
-
expect(Mudis.read("reset_key")).to eq("value")
|
263
|
-
expect(Mudis.current_memory_bytes).to be > 0
|
264
|
-
expect(Mudis.metrics[:hits]).to be >= 0
|
265
|
-
|
266
|
-
Mudis.reset!
|
267
|
-
|
268
|
-
metrics = Mudis.metrics
|
269
|
-
expect(metrics[:hits]).to eq(0)
|
270
|
-
expect(metrics[:misses]).to eq(0)
|
271
|
-
expect(metrics[:evictions]).to eq(0)
|
272
|
-
expect(metrics[:rejected]).to eq(0)
|
273
|
-
expect(Mudis.current_memory_bytes).to eq(0)
|
274
|
-
expect(Mudis.all_keys).to be_empty
|
275
|
-
|
276
|
-
# Optionally confirm reset_key is now gone
|
277
|
-
expect(Mudis.read("reset_key")).to be_nil
|
278
|
-
end
|
279
|
-
end
|
280
|
-
|
281
|
-
describe ".reset_metrics!" do
|
282
|
-
it "resets only the metrics without clearing cache" do
|
283
|
-
Mudis.write("metrics_key", "value")
|
284
|
-
Mudis.read("metrics_key") # generates :hits
|
285
|
-
Mudis.read("missing_key") # generates :misses
|
286
|
-
|
287
|
-
expect(Mudis.metrics[:hits]).to eq(1)
|
288
|
-
expect(Mudis.metrics[:misses]).to eq(1)
|
289
|
-
|
290
|
-
Mudis.reset_metrics!
|
291
|
-
|
292
|
-
expect(Mudis.metrics[:hits]).to eq(0)
|
293
|
-
expect(Mudis.metrics[:misses]).to eq(0)
|
294
|
-
expect(Mudis.read("metrics_key")).to eq("value") # still exists
|
295
|
-
end
|
296
|
-
end
|
297
183
|
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe "Mudis Namespace Operations" do # rubocop:disable Metrics/BlockLength
|
6
|
+
before(:each) do
|
7
|
+
Mudis.reset!
|
8
|
+
end
|
9
|
+
|
10
|
+
it "uses thread-local namespace in block" do
|
11
|
+
Mudis.with_namespace("test") do
|
12
|
+
Mudis.write("foo", "bar")
|
13
|
+
end
|
14
|
+
expect(Mudis.read("foo", namespace: "test")).to eq("bar")
|
15
|
+
expect(Mudis.read("foo")).to be_nil
|
16
|
+
end
|
17
|
+
|
18
|
+
it "supports explicit namespace override" do
|
19
|
+
Mudis.write("x", 1, namespace: "alpha")
|
20
|
+
Mudis.write("x", 2, namespace: "beta")
|
21
|
+
expect(Mudis.read("x", namespace: "alpha")).to eq(1)
|
22
|
+
expect(Mudis.read("x", namespace: "beta")).to eq(2)
|
23
|
+
expect(Mudis.read("x")).to be_nil
|
24
|
+
end
|
25
|
+
|
26
|
+
describe ".keys" do
|
27
|
+
it "returns only keys for the given namespace" do
|
28
|
+
Mudis.write("user:1", "Alice", namespace: "users")
|
29
|
+
Mudis.write("user:2", "Bob", namespace: "users")
|
30
|
+
Mudis.write("admin:1", "Charlie", namespace: "admins")
|
31
|
+
|
32
|
+
result = Mudis.keys(namespace: "users")
|
33
|
+
expect(result).to contain_exactly("user:1", "user:2")
|
34
|
+
end
|
35
|
+
|
36
|
+
it "returns an empty array if no keys exist for namespace" do
|
37
|
+
expect(Mudis.keys(namespace: "nonexistent")).to eq([])
|
38
|
+
end
|
39
|
+
|
40
|
+
it "raises an error if namespace is missing" do
|
41
|
+
expect { Mudis.keys(namespace: nil) }.to raise_error(ArgumentError, /namespace is required/)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe ".clear_namespace" do
|
46
|
+
it "deletes all keys in the given namespace" do
|
47
|
+
Mudis.write("a", 1, namespace: "ns1")
|
48
|
+
Mudis.write("b", 2, namespace: "ns1")
|
49
|
+
Mudis.write("x", 9, namespace: "ns2")
|
50
|
+
|
51
|
+
expect(Mudis.read("a", namespace: "ns1")).to eq(1)
|
52
|
+
expect(Mudis.read("b", namespace: "ns1")).to eq(2)
|
53
|
+
|
54
|
+
Mudis.clear_namespace(namespace: "ns1")
|
55
|
+
|
56
|
+
expect(Mudis.read("a", namespace: "ns1")).to be_nil
|
57
|
+
expect(Mudis.read("b", namespace: "ns1")).to be_nil
|
58
|
+
expect(Mudis.read("x", namespace: "ns2")).to eq(9)
|
59
|
+
end
|
60
|
+
|
61
|
+
it "does nothing if namespace has no keys" do
|
62
|
+
expect { Mudis.clear_namespace(namespace: "ghost") }.not_to raise_error
|
63
|
+
end
|
64
|
+
|
65
|
+
it "raises an error if namespace is nil" do
|
66
|
+
expect { Mudis.clear_namespace(namespace: nil) }.to raise_error(ArgumentError, /namespace is required/)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
data/spec/reset_spec.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe "Mudis Reset Features" do
|
6
|
+
before { Mudis.reset! }
|
7
|
+
|
8
|
+
describe ".reset!" do
|
9
|
+
it "clears all stores, memory, and metrics" do
|
10
|
+
Mudis.write("reset_key", "value")
|
11
|
+
expect(Mudis.read("reset_key")).to eq("value")
|
12
|
+
Mudis.reset!
|
13
|
+
expect(Mudis.metrics[:hits]).to eq(0)
|
14
|
+
expect(Mudis.all_keys).to be_empty
|
15
|
+
expect(Mudis.read("reset_key")).to be_nil
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe ".reset_metrics!" do
|
20
|
+
it "resets only the metrics without clearing cache" do
|
21
|
+
Mudis.write("metrics_key", "value")
|
22
|
+
Mudis.read("metrics_key")
|
23
|
+
Mudis.read("missing_key")
|
24
|
+
expect(Mudis.metrics[:hits]).to eq(1)
|
25
|
+
expect(Mudis.metrics[:misses]).to eq(1)
|
26
|
+
Mudis.reset_metrics!
|
27
|
+
expect(Mudis.metrics[:hits]).to eq(0)
|
28
|
+
expect(Mudis.read("metrics_key")).to eq("value")
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: mudis
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.6.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- kiebor81
|
@@ -54,8 +54,13 @@ files:
|
|
54
54
|
- lib/mudis_config.rb
|
55
55
|
- sig/mudis.rbs
|
56
56
|
- sig/mudis_config.rbs
|
57
|
+
- spec/eviction_spec.rb
|
57
58
|
- spec/guardrails_spec.rb
|
59
|
+
- spec/memory_guard_spec.rb
|
60
|
+
- spec/metrics_spec.rb
|
58
61
|
- spec/mudis_spec.rb
|
62
|
+
- spec/namespace_spec.rb
|
63
|
+
- spec/reset_spec.rb
|
59
64
|
homepage: https://github.com/kiebor81/mudis
|
60
65
|
licenses:
|
61
66
|
- MIT
|
@@ -81,5 +86,10 @@ specification_version: 4
|
|
81
86
|
summary: A fast in-memory, thread-safe and high performance Ruby LRU cache with compression
|
82
87
|
and auto-expiry.
|
83
88
|
test_files:
|
89
|
+
- spec/eviction_spec.rb
|
84
90
|
- spec/guardrails_spec.rb
|
91
|
+
- spec/memory_guard_spec.rb
|
92
|
+
- spec/metrics_spec.rb
|
85
93
|
- spec/mudis_spec.rb
|
94
|
+
- spec/namespace_spec.rb
|
95
|
+
- spec/reset_spec.rb
|