mudis 0.4.3 → 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: 6a4a27b63d070e4a8e356d6ffed09eda290c7f633ffb3bf7b9008499170b4a55
4
- data.tar.gz: 1ccaf9415f6e595272884c926615861975f376d5a52c7109a845dcb4b4480e8d
3
+ metadata.gz: 6207a8171eb9fd9723889d90a5a5e696dafc9a894c29d9b6f99340b7c02ec697
4
+ data.tar.gz: a18de4b21e8b4116c321393d0ada12da91c06682ff099907be84b6ac78c6f999
5
5
  SHA512:
6
- metadata.gz: 2fbcea123de9db1aefcb31e8aea21069bbbdae11300b3a6dd2b73b65c801c6b9dbdc5f60d5d84e001e26059a2e6a2e1e1ef01b61e23261f143cbf0ae3c129e40
7
- data.tar.gz: 3344818c66b36d4fbb9c5e5e287a3067ee7a8c6779b2803084b8b7d4001337e073ccd45406665f2e50bc27ef58ac0e4bc9a17168f85fb302e2b0331cbc11ba2c
6
+ metadata.gz: e44da4c093e85012cee92cc61be4062c48fe94fe46b22210c1433e47b8eef91755359a658f936075ee514a5adcbf5ea2dae184031042532d13907d619ab77876
7
+ data.tar.gz: 79c1703d2d03938d801d752c7cc3c7c98ed777933e85b091ece31180d1dd2b9eaf0af8e8b8bd30221750cd89aa5cfb2989e8914aa4ef09fe6d1316a3ee678024
data/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  ![mudis_signet](design/mudis.png "Mudis")
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/mudis.svg?icon=si%3Arubygems&refresh=1)](https://badge.fury.io/rb/mudis)
3
+ [![Gem Version](https://badge.fury.io/rb/mudis.svg?icon=si%3Arubygems&refresh=1&cachebust=0)](https://badge.fury.io/rb/mudis)
4
4
 
5
5
  **Mudis** is a fast, thread-safe, in-memory, sharded LRU (Least Recently Used) cache for Ruby applications. Inspired by Redis, it provides value serialization, optional compression, per-key expiry, and metric tracking in a lightweight, dependency-free package that lives inside your Ruby process.
6
6
 
7
7
  It’s ideal for scenarios where performance and process-local caching are critical, and where a full Redis setup is overkill or otherwise not possible/desirable.
8
8
 
9
- Alternatively, Mudis can be upscaled with higher sharding and resources in a dedicated Rails app to provide a Mudis server.
9
+ Alternatively, Mudis can be upscaled with higher sharding and resources in a dedicated Rails app to provide a [Mudis server](#create-a-mudis-server).
10
10
 
11
11
  ### Why another Caching Gem?
12
12
 
@@ -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
  # ...
@@ -305,11 +324,12 @@ end
305
324
  | `Mudis.serializer` | JSON, Marshal, or Oj | `JSON` |
306
325
  | `Mudis.compress` | Enable Zlib compression | `false` |
307
326
  | `Mudis.max_value_bytes` | Max allowed size in bytes for a value | `nil` (no limit) |
308
- | `Mudis.buckets` | Number of cache shards (via ENV var) | `32` |
309
- | `start_expiry_thread` | Background TTL cleanup loop (every N sec) | Disabled by default|
310
- | `hard_memory_limit` | Enforce hard memory limits on key size and reject if exceeded | `false`|
311
- | `max_bytes` | Maximum allowed cache size | `1GB`|
312
- | `buckets` | Number of memory buckets (shards) | `32` |
327
+ | `Mudis.buckets` | Number of cache shards | `32` |
328
+ | `Mudis.start_expiry_thread` | Background TTL cleanup loop (every N sec) | Disabled by default|
329
+ | `Mudis.hard_memory_limit` | Enforce hard memory limits on key size and reject if exceeded | `false`|
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` |
313
333
 
314
334
  Buckets can also be set using a `MUDIS_BUCKETS` environment variable.
315
335
 
@@ -321,21 +341,69 @@ When setting `serializer`, be mindful of the below
321
341
  | `JSON` | Cross-language interoperability |
322
342
  | `Oj` | API-heavy apps using JSON at scale |
323
343
 
324
- #### Benchmarks
344
+ ---
345
+
346
+ ## Benchmarks
325
347
 
326
- Based on 100000 iterations
348
+ #### Serializer(s)
327
349
 
328
- | Serializer | Iterations | Total Time (s) | Ops/sec |
329
- |----------------|------------|----------------|---------|
330
- | oj | 100000 | 0.1342 | 745320 |
331
- | marshal | 100000 | 0.3228 | 309824 |
332
- | json | 100000 | 0.9035 | 110682 |
333
- | oj + zlib | 100000 | 1.8050 | 55401 |
334
- | marshal + zlib | 100000 | 1.8057 | 55381 |
335
- | json + zlib | 100000 | 2.7949 | 35780 |
350
+ _100000 iterations_
351
+
352
+ | Serializer | Total Time (s) | Ops/sec |
353
+ |----------------|------------|----------------|
354
+ | oj | 0.1342 | 745320 |
355
+ | marshal | 0.3228 | 309824 |
356
+ | json | 0.9035 | 110682 |
357
+ | oj + zlib | 1.8050 | 55401 |
358
+ | marshal + zlib | 1.8057 | 55381 |
359
+ | json + zlib | 2.7949 | 35780 |
336
360
 
337
361
  > If opting for OJ, you will need to install the dependency in your project and configure as needed.
338
362
 
363
+ #### Mudis vs Rails.cache
364
+
365
+ Mudis is marginally slower than `Rails.cache` by design; it trades raw speed for control, observability, and safety.
366
+
367
+ _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression ON_
368
+
369
+ | Operation | `Rails.cache` | `Mudis` | Delta |
370
+ | --------- | ------------- | ----------- | --------- |
371
+ | Write | 2.139 ms/op | 2.417 ms/op | +0.278 ms |
372
+ | Read | 0.007 ms/op | 0.810 ms/op | +0.803 ms |
373
+
374
+ > For context: a typical database query or HTTP call takes 10–50ms. A difference of less than 1ms per operation is negligible for most apps.
375
+
376
+ ###### **Why this overhead exists**
377
+
378
+ Mudis includes features that MemoryStore doesn’t:
379
+
380
+ | Feature | Mudis | Rails.cache (MemoryStore) |
381
+ | ------------------ | ---------------------- | --------------------------- |
382
+ | Per-key TTL expiry | ✅ | ⚠️ on access |
383
+ | True LRU eviction | ✅ | ❌ |
384
+ | Hard memory limits | ✅ | ❌ |
385
+ | Value compression | ✅ | ❌ |
386
+ | Thread safety | ✅ Bucket-level mutexes | ✅ Global mutex |
387
+ | Observability | ✅ | ❌ |
388
+ | Namespacing | ✅ | ❌ Manual scoping |
389
+
390
+ It will be down to the developer to decide if a fraction of a millisecond is worth
391
+
392
+ - Predictable eviction
393
+ - Configurable expiry
394
+ - Memory protection
395
+ - Namespace scoping
396
+ - Real-time metrics for hits, misses, evictions, memory usage
397
+
398
+ _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression OFF (to match MemoryStore default)_
399
+
400
+ | Operation | `Rails.cache` | `Mudis` | Delta |
401
+ | --------- | ------------- | ----------- | ------------- |
402
+ | Write | 2.342 ms/op | 0.501 ms/op | **−1.841 ms** |
403
+ | Read | 0.007 ms/op | 0.011 ms/op | +0.004 ms |
404
+
405
+ With compression disabled, Mudis writes significanty faster and reads are virtually identical. Optimisation and configuration of Mudis will be determined by your individual needs.
406
+
339
407
  ---
340
408
 
341
409
  ## Graceful Shutdown
@@ -355,6 +423,115 @@ at_exit { Mudis.stop_expiry_thread }
355
423
 
356
424
  ---
357
425
 
426
+ ## Create a Mudis Server
427
+
428
+ ### Minimal Setup
429
+
430
+ - Create a new Rails API app:
431
+
432
+ ```bash
433
+ rails new mudis-server --api
434
+ cd mudis-server
435
+ ```
436
+
437
+ - Add mudis to your Gemfile
438
+ - Create Initializer: `config/initializers/mudis.rb`
439
+ - Define routes
440
+
441
+ ```ruby
442
+ Rails.application.routes.draw do
443
+ get "/cache/:key", to: "cache#show"
444
+ post "/cache/:key", to: "cache#write"
445
+ delete "/cache/:key", to: "cache#delete"
446
+ get "/metrics", to: "cache#metrics"
447
+ end
448
+ ```
449
+
450
+ - Create a `cache_controller` (with optional per caller/consumer namespace)
451
+
452
+ ```ruby
453
+ class CacheController < ApplicationController
454
+
455
+ def show
456
+ key = params[:key]
457
+ ns = params[:namespace]
458
+
459
+ value = Mudis.read(key, namespace: ns)
460
+ if value.nil?
461
+ render json: { error: "not found" }, status: :not_found
462
+ else
463
+ render json: { value: value }
464
+ end
465
+ end
466
+
467
+ def write
468
+ key = params[:key]
469
+ ns = params[:namespace]
470
+ val = params[:value]
471
+ ttl = params[:expires_in]&.to_i
472
+
473
+ Mudis.write(key, val, expires_in: ttl, namespace: ns)
474
+ render json: { status: "written", key: key }
475
+ end
476
+
477
+ def delete
478
+ key = params[:key]
479
+ ns = params[:namespace]
480
+
481
+ Mudis.delete(key, namespace: ns)
482
+ render json: { status: "deleted" }
483
+ end
484
+
485
+ def metrics
486
+ render json: Mudis.metrics
487
+ end
488
+ end
489
+ ```
490
+
491
+ - Test it
492
+
493
+ ```bash
494
+ curl http://localhost:3000/cache/foo
495
+ curl -X POST http://localhost:3000/cache/foo -d 'value=bar&expires_in=60'
496
+ curl http://localhost:3000/metrics
497
+
498
+ # Write with namespace
499
+ curl -X POST "http://localhost:3000/cache/foo?namespace=orders" \
500
+ -d "value=123&expires_in=60"
501
+
502
+ # Read from namespace
503
+ curl "http://localhost:3000/cache/foo?namespace=orders"
504
+
505
+ # Delete from namespace
506
+ curl -X DELETE "http://localhost:3000/cache/foo?namespace=orders"
507
+
508
+ ```
509
+
510
+ ---
511
+
512
+ ## Project Philosophy
513
+
514
+ Mudis is intended to be a minimal, thread-safe, in-memory cache designed specifically for Ruby applications. It focuses on:
515
+
516
+ - In-process caching
517
+ - Fine-grained memory and namespace control
518
+ - Observability and testing friendliness
519
+ - Minimal external dependencies
520
+ - Configurability without complexity
521
+
522
+ The primary use cases are:
523
+
524
+ - Per-service application caches
525
+ - Short-lived local caching inside background jobs or API layers
526
+
527
+ Mudis is not intended to be a general-purpose, distributed caching platform. You are, however, welcome to build on top of Mudis if you want its functionality in such projects. E.g.,
528
+
529
+ - mudis-server – expose Mudis via HTTP, web sockets, hooks, etc
530
+ - mudis-broker – distributed key routing layer for coordinating multiple Mudis nodes
531
+ - mudis-activejob-store – adapter for using Mudis in job queues or retry buffers
532
+
533
+ ---
534
+
358
535
  ## Roadmap
359
536
 
360
537
  #### API Enhancements
@@ -363,8 +540,8 @@ at_exit { Mudis.stop_expiry_thread }
363
540
 
364
541
  #### Safety & Policy Controls
365
542
 
366
- - [ ] max_ttl: Enforce a global upper bound on expires_in to prevent excessively long-lived keys
367
- - [ ] 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
368
545
 
369
546
  #### Debugging
370
547
 
@@ -380,14 +557,7 @@ MIT License © kiebor81
380
557
 
381
558
  ## Contributing
382
559
 
383
- PRs are welcome! To get started:
384
-
385
- ```bash
386
- git clone https://github.com/kiebor81/mudis
387
- cd mudis
388
- bundle install
389
-
390
- ```
560
+ See [contributor's guide](CONTRIBUTING.md)
391
561
 
392
562
  ---
393
563
 
data/lib/mudis/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- MUDIS_VERSION = "0.4.3"
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.3
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kiebor81
@@ -38,8 +38,9 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '3.12'
41
- description: Thread-safe, bucketed, in-process cache for Ruby apps. Drop-in replacement
42
- for Kredis in some scenarios.
41
+ description: Mudis is a fast, thread-safe, in-memory, sharded LRU cache for Ruby applications.
42
+ Inspired by Redis, it provides value serialization, optional compression, per-key
43
+ expiry, and metric tracking in a lightweight, dependency-free package.
43
44
  email:
44
45
  executables: []
45
46
  extensions: []
@@ -77,7 +78,8 @@ requirements: []
77
78
  rubygems_version: 3.5.17
78
79
  signing_key:
79
80
  specification_version: 4
80
- summary: A fast in-memory Ruby LRU cache with compression and expiry.
81
+ summary: A fast in-memory, thread-safe and high performance Ruby LRU cache with compression
82
+ and auto-expiry.
81
83
  test_files:
82
84
  - spec/guardrails_spec.rb
83
85
  - spec/mudis_spec.rb