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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b180000138c976ee0e0c7a7cdc068550c5f33f39f363c63ba6397b84fe8f6cbe
4
- data.tar.gz: ee6de323fd9dcc5914ec0fde68da5fff7289e6c830395b9d4be4271d389b2c15
3
+ metadata.gz: aeea820874569fe79af99d53f2a3ae3a7a9b74dc8a2d9dccbfbaa3f4ebab3967
4
+ data.tar.gz: 211d8c786790478e861461aab7444bd4bd1342afa07039ef3608f66dbd401cd6
5
5
  SHA512:
6
- metadata.gz: e7d2c14ca2b2dce8ed23f5fb371fe73456d92d9613e25d75fb645d1a6903259a21dd31dc7e3acca8f4336dc77990d5f33fce9254e3becdbcc59d7c19c526a302
7
- data.tar.gz: 6619380f6d7206caec28c0e1f3f5450bb8991817fff6c08d6e7b5ceff2c82edc8a7c7b51b38894e5fb5a09e5f41a0546c50b951d088accfc1dc70a91935d398d
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
- - [ ] bulk_read(keys, namespace:): Batch retrieval of multiple keys with a single method call
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
- - [ ] max_ttl: Enforce a global upper bound on expires_in to prevent excessively long-lived keys
523
- - [ ] default_ttl: Provide a fallback TTL when one is not specified
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
- - [ ] clear_namespace(namespace): Remove all keys in a namespace in one call
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
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- MUDIS_VERSION = "0.4.4"
3
+ MUDIS_VERSION = "0.6.0"
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 : 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 buckets : Integer?
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
@@ -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 ".metrics" do
232
- it "includes per-bucket stats" do
233
- Mudis.write("a", "x" * 50)
234
- metrics = Mudis.metrics
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
- expect(metrics).to include(:buckets)
237
- expect(metrics[:buckets]).to be_an(Array)
238
- expect(metrics[:buckets].first).to include(:index, :keys, :memory_bytes, :lru_size)
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
@@ -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.4
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