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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b180000138c976ee0e0c7a7cdc068550c5f33f39f363c63ba6397b84fe8f6cbe
4
- data.tar.gz: ee6de323fd9dcc5914ec0fde68da5fff7289e6c830395b9d4be4271d389b2c15
3
+ metadata.gz: 6207a8171eb9fd9723889d90a5a5e696dafc9a894c29d9b6f99340b7c02ec697
4
+ data.tar.gz: a18de4b21e8b4116c321393d0ada12da91c06682ff099907be84b6ac78c6f999
5
5
  SHA512:
6
- metadata.gz: e7d2c14ca2b2dce8ed23f5fb371fe73456d92d9613e25d75fb645d1a6903259a21dd31dc7e3acca8f4336dc77990d5f33fce9254e3becdbcc59d7c19c526a302
7
- data.tar.gz: 6619380f6d7206caec28c0e1f3f5450bb8991817fff6c08d6e7b5ceff2c82edc8a7c7b51b38894e5fb5a09e5f41a0546c50b951d088accfc1dc70a91935d398d
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
- - [ ] 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
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
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- MUDIS_VERSION = "0.4.4"
3
+ MUDIS_VERSION = "0.5.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,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 : 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
@@ -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|
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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kiebor81