mudis 0.7.2 → 0.7.3

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.
data/README.md CHANGED
@@ -1,863 +1,863 @@
1
- ![mudis_signet](design/mudis.png "Mudis")
2
-
3
- [![Gem Version](https://badge.fury.io/rb/mudis.svg?icon=si%3Arubygems&refresh=1&cachebust=0)](https://badge.fury.io/rb/mudis)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
-
6
- **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.
7
-
8
- 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.
9
-
10
- Alternatively, Mudis can be upscaled with higher sharding and resources in a dedicated Rails app to provide a [Mudis Web Cache Server](#create-a-mudis-web-cache-server).
11
-
12
- Mudis also works naturally in Hanami because it’s a pure Ruby in-memory cache. Whether used as a singleton within a process or via IPC in cluster mode, it preserves Hanami’s lightweight and modular architecture.
13
-
14
- ---
15
-
16
- ## Table of Contents
17
-
18
- - [Why Another Caching Gem?](#why-another-caching-gem)
19
- - [Similar Gems](#similar-gems)
20
- - [Feature / Function Comparison](#feature--function-comparison)
21
- - [Design](#design)
22
- - [Internal Structure and Behaviour](#internal-structure-and-behaviour)
23
- - [Write - Read - Eviction](#write---read---eviction)
24
- - [Cache Key Lifecycle](#cache-key-lifecycle)
25
- - [Features](#features)
26
- - [Installation](#installation)
27
- - [Configuration (Rails)](#configuration-rails)
28
- - [Configuration (Hanami)](#configuration-hanami)
29
- - [Basic Usage](#basic-usage)
30
- - [Developer Utilities](#developer-utilities)
31
- - [`Mudis.reset!`](#mudisreset)
32
- - [`Mudis.reset_metrics!`](#mudisreset_metrics)
33
- - [`Mudis.least_touched`](#mudisleast_touched)
34
- - [`Mudis.keys(namespace:)`](#mudiskeysnamespace)
35
- - [`Mudis.clear_namespace(namespace:)`](#mudisclear_namespacenamespace)
36
- - [Rails Service Integration](#rails-service-integration)
37
- - [Metrics](#metrics)
38
- - [Advanced Configuration](#advanced-configuration)
39
- - [Benchmarks](#benchmarks)
40
- - [Graceful Shutdown](#graceful-shutdown)
41
- - [Known Limitations](#known-limitations)
42
- - [Inter-Process Caching (IPC Mode)](#inter-process-caching-ipc-mode)
43
- - [Overview](#overview)
44
- - [Setup (Puma)](#setup-puma)
45
- - [Create a Mudis Web Cache Server](#create-a-mudis-web-cache-server)
46
- - [Minimal Setup](#minimal-setup)
47
- - [Project Philosophy](#project-philosophy)
48
- - [Roadmap](#roadmap)
49
-
50
- ---
51
-
52
- ### Why another Caching Gem?
53
-
54
- There are plenty out there, in various states of maintenance and in many shapes and sizes. So why on earth do we need another? I needed a drop-in replacement for Kredis, and the reason I was interested in using Kredis was for the simplified API and keyed management it gave me in extension to Redis. But what I didn't really need was Redis. I needed an observable, fast, simple, easy to use, flexible and highly configurable, thread-safe and high performant caching system which didn't require too many dependencies or standing up additional services. So, Mudis was born. In its most rudimentary state it was extremely useful in my project, which was an API gateway connecting into mutliple micro-services and a wide selection of APIs. The majority of the data was cold and produced by repeat expensive queries across several domains. Mudis allowed for me to minimize the footprint of the gateway, and improve end user experience, and increase performance. So, yeah, there's a lot of these gems out there, but none which really met all my needs. I decided to provide Mudis for anyone else. If you use it, I'd be interested to know how and whether you got any benefit.
55
-
56
- #### Similar Gems
57
-
58
- - [FastCache](https://github.com/swoop-inc/fast_cache)
59
- - [EasyCache](https://github.com/malvads/easycache)
60
- - [MiniCache](https://github.com/derrickreimer/mini_cache)
61
- - [Zache](https://github.com/yegor256/zache)
62
-
63
- #### Feature / Function Comparison
64
-
65
- | **Feature** | **Mudis** | **MemoryStore** (`Rails.cache`) | **FastCache** | **Zache** | **EasyCache** | **MiniCache** |
66
- | -------------------------------------- | ---------------- | ------------------------------- | -------------- | ------------- | ------------- | -------------- |
67
- | **LRU eviction strategy** | ✅ Per-bucket | ✅ Global | ✅ Global | ❌ | ❌ | ✅ Simplistic |
68
- | **TTL expiry support** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
69
- | **Background expiry cleanup thread** | ✅ | ❌ (only on access) | ❌ | ✅ | ❌ | ❌ |
70
- | **Thread safety** | ✅ Bucketed | ⚠️ Global lock | ✅ Fine-grained | ✅ | ⚠️ | ⚠️ |
71
- | **Sharding (buckets)** | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
72
- | **Custom serializers** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
73
- | **Compression (Zlib)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
74
- | **Hard memory cap** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
75
- | **Max value size enforcement** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
76
- | **Metrics (hits, misses, evictions)** | ✅ | ⚠️ Partial | ❌ | ❌ | ❌ | ❌ |
77
- | **Fetch/update pattern** | ✅ Full | ✅ Standard | ⚠️ Partial | ✅ Basic | ✅ Basic | ✅ Basic |
78
- | **Namespacing** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
79
- | **Replace (if exists)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
80
- | **Clear/delete method** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
81
- | **Key inspection with metadata** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
82
- | **Concurrency model** | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
83
- | **Maintenance level** | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ |
84
- | **Suitable for APIs or microservices** | ✅ | ⚠️ Limited | ✅ | ⚠️ Small apps | ⚠️ Small apps | ❌ |
85
- | **Inter-process Caching** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
86
-
87
- ---
88
-
89
- ## Design
90
-
91
- #### Internal Structure and Behaviour
92
-
93
- ![mudis_flow](design/mudis_obj.png "Mudis Internals")
94
-
95
- #### Write - Read - Eviction
96
-
97
- ![mudis_flow](design/mudis_flow.png "Write - Read - Eviction")
98
-
99
- #### Cache Key Lifecycle
100
-
101
- ![mudis_lru](design/mudis_lru.png "Mudis Cache Key Lifecycle")
102
-
103
- ---
104
-
105
- ## Features
106
-
107
- - **Thread-safe**: Uses per-bucket mutexes for high concurrency.
108
- - **Sharded**: Buckets data across multiple internal stores to minimize lock contention.
109
- - **LRU Eviction**: Automatically evicts least recently used items as memory fills up.
110
- - **Expiry Support**: Optional TTL per key with background cleanup thread.
111
- - **Compression**: Optional Zlib compression for large values.
112
- - **Metrics**: Tracks hits, misses, and evictions.
113
- - **IPC Mode**: Shared cross-process caching for multi-process aplications.
114
-
115
- ---
116
-
117
- ## Installation
118
-
119
- Add this line to your Gemfile:
120
-
121
- ```ruby
122
- gem 'mudis'
123
- ```
124
-
125
- Or install it manually:
126
-
127
- ```bash
128
- gem install mudis
129
- ```
130
-
131
- ---
132
-
133
- ## Configuration (Rails)
134
-
135
- In your Rails app, create an initializer:
136
-
137
- ```ruby
138
- # config/initializers/mudis.rb
139
- Mudis.configure do |c|
140
- c.serializer = JSON # or Marshal | Oj
141
- c.compress = true # Compress values using Zlib
142
- c.max_value_bytes = 2_000_000 # Reject values > 2MB
143
- c.hard_memory_limit = true # enforce hard memory limits
144
- c.max_bytes = 1_073_741_824 # set maximum cache size
145
- end
146
-
147
- Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
148
-
149
- at_exit do
150
- Mudis.stop_expiry_thread
151
- end
152
- ```
153
-
154
- Or with direct setters:
155
-
156
- ```ruby
157
- Mudis.serializer = JSON # or Marshal | Oj
158
- Mudis.compress = true # Compress values using Zlib
159
- Mudis.max_value_bytes = 2_000_000 # Reject values > 2MB
160
- Mudis.hard_memory_limit = true # enforce hard memory limits
161
- Mudis.max_bytes = 1_073_741_824 # set maximum cache size
162
-
163
- Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
164
-
165
- ## set at exit hook
166
- ```
167
-
168
- ---
169
-
170
- ## Configuration (Hanami)
171
-
172
- Mudis integrates seamlessly with [Hanami](https://hanamirb.org) applications. It provides the same configuration flexibility and thread-safe caching capabilities as in Rails, with minimal setup differences.
173
-
174
- Create a boot file:
175
-
176
- ```ruby
177
- # config/boot/mudis.rb
178
- require "mudis"
179
-
180
- Mudis.configure do |c|
181
- c.serializer = JSON
182
- c.compress = true
183
- c.max_value_bytes = 2_000_000
184
- c.hard_memory_limit = true
185
- c.max_bytes = 1_073_741_824
186
- end
187
-
188
- Mudis.start_expiry_thread(interval: 60)
189
-
190
- at_exit { Mudis.stop_expiry_thread }
191
- ```
192
-
193
- Then require it from `config/app.rb`:
194
-
195
- ```ruby
196
- # config/app.rb
197
- require_relative "./boot/mudis"
198
-
199
- module MyApp
200
- class App < Hanami::App
201
- end
202
- end
203
- ```
204
-
205
- This ensures Mudis is initialized and available globally throughout your Hanami application in the same as it would in Rails.
206
-
207
- ---
208
-
209
- ### Using Mudis with Hanami’s Dependency Container
210
-
211
- You can register Mudis as a dependency in the Hanami container to access it via dependency injection:
212
-
213
- ```ruby
214
- # config/container.rb
215
- require "mudis"
216
-
217
- MyApp::Container.register(:cache, Mudis)
218
- ```
219
-
220
- Then use it inside your actions, repositories, or services:
221
-
222
- ```ruby
223
- # apps/main/actions/users/show.rb
224
- module Main
225
- module Actions
226
- module Users
227
- class Show < Main::Action
228
- include Deps[cache: "cache"]
229
-
230
- def handle(req, res)
231
- res[:user] = cache.fetch("user:#{req.params[:id]}", expires_in: 60) do
232
- UserRepo.new.find(req.params[:id])
233
- end
234
- end
235
- end
236
- end
237
- end
238
- end
239
- ```
240
-
241
- ---
242
-
243
- ## Basic Usage
244
-
245
- ```ruby
246
- require 'mudis'
247
-
248
- # Write a value with optional TTL
249
- Mudis.write('user:123', { name: 'Alice' }, expires_in: 600)
250
-
251
- # Read it back
252
- Mudis.read('user:123') # => { "name" => "Alice" }
253
-
254
- # Check if it exists
255
- Mudis.exists?('user:123') # => true
256
-
257
- # Atomically update
258
- Mudis.update('user:123') { |data| data.merge(age: 30) }
259
-
260
- # Delete a key
261
- Mudis.delete('user:123')
262
- ```
263
-
264
- ### Developer Utilities
265
-
266
- Mudis provides utility methods to help with test environments, console debugging, and dev tool resets.
267
-
268
- #### `Mudis.reset!`
269
- Clears the internal cache state. Including all keys, memory tracking, and metrics. Also stops the expiry thread.
270
-
271
- ```ruby
272
- Mudis.write("foo", "bar")
273
- Mudis.reset!
274
- Mudis.read("foo") # => nil
275
- ```
276
-
277
- - Wipe all buckets (@stores, @lru_nodes, @current_bytes)
278
- - Reset all metrics (:hits, :misses, :evictions, :rejected)
279
- - Stop any running background expiry thread
280
-
281
- #### `Mudis.reset_metrics!`
282
-
283
- Clears only the metric counters and preserves all cached values.
284
-
285
- ```ruby
286
- Mudis.write("key", "value")
287
- Mudis.read("key") # => "value"
288
- Mudis.metrics # => { hits: 1, misses: 0, ... }
289
-
290
- Mudis.reset_metrics!
291
- Mudis.metrics # => { hits: 0, misses: 0, ... }
292
- Mudis.read("key") # => "value" (still cached)
293
- ```
294
-
295
- #### `Mudis.least_touched`
296
-
297
- 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.
298
-
299
- Each result includes the full key and its access count.
300
-
301
- ```ruby
302
- Mudis.least_touched
303
- # => [["foo", 0], ["user:42", 1], ["product:123", 2], ...]
304
-
305
- Mudis.least_touched(5)
306
- # => returns top 5 least accessed keys
307
- ```
308
-
309
- #### `Mudis.keys(namespace:)`
310
-
311
- Returns all keys for a given namespace.
312
-
313
- ```ruby
314
- Mudis.write("u1", "alpha", namespace: "users")
315
- Mudis.write("u2", "beta", namespace: "users")
316
-
317
- Mudis.keys(namespace: "users")
318
- # => ["u1", "u2"]
319
-
320
- ```
321
-
322
- #### `Mudis.clear_namespace(namespace:)`
323
-
324
- Deletes all keys within a namespace.
325
-
326
- ```ruby
327
- Mudis.clear_namespace("users")
328
- Mudis.read("u1", namespace: "users") # => nil
329
- ```
330
-
331
- ---
332
-
333
- ## Rails Service Integration
334
-
335
- For simplified or transient use in a controller, you can wrap your cache logic in a reusable thin class:
336
-
337
- ```ruby
338
- class MudisService
339
- attr_reader :cache_key, :namespace
340
-
341
- # Initialize the service with a cache key and optional namespace
342
- #
343
- # @param cache_key [String] the base key to use
344
- # @param namespace [String, nil] optional logical namespace
345
- def initialize(cache_key, namespace: nil)
346
- @cache_key = cache_key
347
- @namespace = namespace
348
- end
349
-
350
- # Write a value to the cache
351
- #
352
- # @param data [Object] the value to cache
353
- # @param expires_in [Integer, nil] optional TTL in seconds
354
- def write(data, expires_in: nil)
355
- Mudis.write(cache_key, data, expires_in: expires_in, namespace: namespace)
356
- end
357
-
358
- # Read the cached value or return default
359
- #
360
- # @param default [Object] fallback value if key is not present
361
- def read(default: nil)
362
- Mudis.read(cache_key, namespace: namespace) || default
363
- end
364
-
365
- # Update the cached value using a block
366
- #
367
- # @yieldparam current [Object] the current value
368
- # @yieldreturn [Object] the updated value
369
- def update
370
- Mudis.update(cache_key, namespace: namespace) { |current| yield(current) }
371
- end
372
-
373
- # Delete the key from cache
374
- def delete
375
- Mudis.delete(cache_key, namespace: namespace)
376
- end
377
-
378
- # Return true if the key exists in cache
379
- def exists?
380
- Mudis.exists?(cache_key, namespace: namespace)
381
- end
382
-
383
- # Fetch from cache or compute and store it
384
- #
385
- # @param expires_in [Integer, nil] optional TTL
386
- # @param force [Boolean] force recomputation
387
- # @yield return value if key is missing
388
- def fetch(expires_in: nil, force: false)
389
- Mudis.fetch(cache_key, expires_in: expires_in, force: force, namespace: namespace) do
390
- yield
391
- end
392
- end
393
-
394
- # Inspect metadata for the current key
395
- #
396
- # @return [Hash, nil] metadata including :expires_at, :created_at, :size_bytes, etc.
397
- def inspect_meta
398
- Mudis.inspect(cache_key, namespace: namespace)
399
- end
400
- end
401
-
402
- ```
403
-
404
- Use it like:
405
-
406
- ```ruby
407
- cache = MudisService.new("user:42:profile", namespace: "users")
408
-
409
- cache.write({ name: "Alice" }, expires_in: 300)
410
- cache.read # => { "name" => "Alice" }
411
- cache.exists? # => true
412
-
413
- cache.update { |data| data.merge(age: 30) }
414
- cache.fetch(expires_in: 60) { expensive_query }
415
- cache.inspect_meta # => { key: "users:user:42:profile", ... }
416
- ```
417
-
418
- *This pattern can also be applied in Hanami services using the registered Mudis dependency*
419
-
420
- ---
421
-
422
- ## Metrics
423
-
424
- Track cache effectiveness and performance:
425
-
426
- ```ruby
427
- Mudis.metrics
428
- # => {
429
- # hits: 15,
430
- # misses: 5,
431
- # evictions: 3,
432
- # rejected: 0,
433
- # total_memory: 45678,
434
- # least_touched: [
435
- # ["user:1", 0],
436
- # ["post:5", 1],
437
- # ...
438
- # ],
439
- # buckets: [
440
- # { index: 0, keys: 12, memory_bytes: 12345, lru_size: 12 },
441
- # ...
442
- # ]
443
- # }
444
-
445
- ```
446
-
447
- Optionally, expose Mudis metrics from a controller or action for remote analysis and monitoring.
448
-
449
- **Rails:**
450
-
451
- ```ruby
452
- class MudisController < ApplicationController
453
- def metrics
454
- render json: { mudis: Mudis.metrics }
455
- end
456
-
457
- end
458
- ```
459
-
460
- **Hanami:**
461
-
462
- *Mudis Registered in the container*
463
-
464
- ```ruby
465
- # apps/main/actions/metrics/show.rb
466
- module Main::Actions::Metrics
467
- class Show < Main::Action
468
- include Deps[cache: "cache"]
469
-
470
- def handle(*, res)
471
- res.format = :json
472
- res.body = { mudis: cache.metrics }.to_json
473
- end
474
- end
475
- end
476
- ```
477
-
478
- *Mudis not registered in the container*
479
-
480
- ```ruby
481
- # apps/main/actions/metrics/show.rb
482
- module Main::Actions::Metrics
483
- class Show < Main::Action
484
- def handle(*, res)
485
- res.format = :json
486
- res.body = { mudis: Mudis.metrics }.to_json
487
- end
488
- end
489
- end
490
- ```
491
-
492
- ```ruby
493
- # config/routes.rb
494
- module Main
495
- class Routes < Hanami::Routes
496
- root { "OK" }
497
-
498
- get "/metrics", to: "metrics.show"
499
- end
500
- end
501
- ```
502
-
503
- ---
504
-
505
- ## Advanced Configuration
506
-
507
- | Setting | Description | Default |
508
- |--------------------------|---------------------------------------------|--------------------|
509
- | `Mudis.serializer` | JSON, Marshal, or Oj | `JSON` |
510
- | `Mudis.compress` | Enable Zlib compression | `false` |
511
- | `Mudis.max_value_bytes` | Max allowed size in bytes for a value | `nil` (no limit) |
512
- | `Mudis.buckets` | Number of cache shards | `32` |
513
- | `Mudis.start_expiry_thread` | Background TTL cleanup loop (every N sec) | Disabled by default|
514
- | `Mudis.hard_memory_limit` | Enforce hard memory limits on key size and reject if exceeded | `false`|
515
- | `Mudis.max_bytes` | Maximum allowed cache size | `1GB`|
516
- | `Mudis.max_ttl` | Set the maximum permitted TTL | `nil` (no limit) |
517
- | `Mudis.default_ttl` | Set the default TTL for fallback when none is provided | `nil` |
518
-
519
- Buckets can also be set using a `MUDIS_BUCKETS` environment variable.
520
-
521
- When setting `serializer`, be mindful of the below
522
-
523
- | Serializer | Recommended for |
524
- | ---------- | ------------------------------------- |
525
- | `Marshal` | Ruby-only apps, speed-sensitive logic |
526
- | `JSON` | Cross-language interoperability |
527
- | `Oj` | API-heavy apps using JSON at scale |
528
-
529
- ---
530
-
531
- ## Benchmarks
532
-
533
- #### Serializer(s)
534
-
535
- _100000 iterations_
536
-
537
- | Serializer | Total Time (s) | Ops/sec |
538
- |----------------|------------|----------------|
539
- | oj | 0.1342 | 745320 |
540
- | marshal | 0.3228 | 309824 |
541
- | json | 0.9035 | 110682 |
542
- | oj + zlib | 1.8050 | 55401 |
543
- | marshal + zlib | 1.8057 | 55381 |
544
- | json + zlib | 2.7949 | 35780 |
545
-
546
- > If opting for OJ, you will need to install the dependency in your project and configure as needed.
547
-
548
- #### Mudis vs Rails.cache
549
-
550
- Mudis is marginally slower than `Rails.cache` by design; it trades raw speed for control, observability, and safety.
551
-
552
- _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression ON_
553
-
554
- | Operation | `Rails.cache` | `Mudis` | Delta |
555
- | --------- | ------------- | ----------- | --------- |
556
- | Write | 2.139 ms/op | 2.417 ms/op | +0.278 ms |
557
- | Read | 0.007 ms/op | 0.810 ms/op | +0.803 ms |
558
-
559
- > 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.
560
-
561
- #### **Why this overhead exists**
562
-
563
- Mudis includes features that MemoryStore doesn’t:
564
-
565
- | Feature | Mudis | Rails.cache (MemoryStore) |
566
- | ------------------ | ---------------------- | --------------------------- |
567
- | Per-key TTL expiry | ✅ | ⚠️ on access |
568
- | True LRU eviction | ✅ | ❌ |
569
- | Hard memory limits | ✅ | ❌ |
570
- | Value compression | ✅ | ❌ |
571
- | Thread safety | ✅ Bucket-level mutexes | ✅ Global mutex |
572
- | Observability | ✅ | ❌ |
573
- | Namespacing | ✅ | ❌ Manual scoping |
574
- | IPC Aware | ✅ (if enabled) | ❌ Requires manual configuration and additional gems |
575
-
576
- It will be down to the developer to decide if a fraction of a millisecond is worth
577
-
578
- - Predictable eviction
579
- - Configurable expiry
580
- - Memory protection
581
- - Namespace scoping
582
- - Real-time metrics for hits, misses, evictions, memory usage
583
-
584
- _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression OFF (to match MemoryStore default)_
585
-
586
- | Operation | `Rails.cache` | `Mudis` | Delta |
587
- | --------- | ------------- | ----------- | ------------- |
588
- | Write | 2.342 ms/op | 0.501 ms/op | **−1.841 ms** |
589
- | Read | 0.007 ms/op | 0.011 ms/op | +0.004 ms |
590
-
591
- With compression disabled, Mudis writes significanty faster and reads are virtually identical. Optimisation and configuration of Mudis will be determined by your individual needs.
592
-
593
- #### Other Benchmarks
594
-
595
- _10000 iterations of 512KB, JSON, compression OFF (to match MemoryStore default)_
596
-
597
- | Operation | `Rails.cache` | `Mudis` | Delta |
598
- | --------- | ------------- | ----------- | ------------- |
599
- | Write | 1.291 ms/op | 0.32 ms/op | **−0.971 ms** |
600
- | Read | 0.011 ms/op | 0.048 ms/op | +0.037 ms |
601
-
602
- _10000 iterations of 512KB, JSON, compression ON_
603
-
604
- | Operation | `Rails.cache` | `Mudis` | Delta |
605
- | --------- | ------------- | ----------- | ------------- |
606
- | Write | 1.11 ms/op | 1.16 ms/op | +0.05 ms |
607
- | Read | 0.07 ms/op | 0.563 ms/op | +0.493 ms |
608
-
609
- ---
610
-
611
- ## Graceful Shutdown
612
-
613
- Don’t forget to stop the expiry thread when your app exits:
614
-
615
- ```ruby
616
- at_exit { Mudis.stop_expiry_thread }
617
- ```
618
-
619
- ---
620
-
621
- ## Known Limitations
622
-
623
- - Data is **non-persistent**.
624
- - Compression introduces CPU overhead.
625
-
626
- ---
627
-
628
- ## Inter-Process Caching (IPC Mode)
629
-
630
- While Mudis was originally designed as an in-process cache, it can also operate as a shared inter-process cache when running in environments that use concurrent processes (such as Puma in cluster mode). This is achieved through a local UNIX socket server that allows all workers to access a single, centralized Mudis instance.
631
-
632
- ### Overview
633
-
634
- In IPC mode, Mudis runs as a singleton server within the master process.
635
-
636
- Each worker connects to that server through a lightweight client (`MudisClient`) using a local UNIX domain socket (default: `/tmp/mudis.sock`).
637
- All cache operations, e.g., read, write, delete, fetch, etc., are transparently proxied to the master process, which holds the authoritative cache state.
638
-
639
- This design allows multiple workers to share the same cache without duplicating memory or losing synchronization, while retaining Mudis’ performance, configurability, and thread safety.
640
-
641
- | **Benefit** | **Description** |
642
- | --------------------------------- | ---------------------------------------------------------------------------------------- |
643
- | **Shared Cache Across Processes** | All Puma workers share one Mudis instance via IPC. |
644
- | **Zero External Dependencies** | No Redis, Memcached, or separate daemon required. |
645
- | **Memory Efficient** | Cache data stored only once, not duplicated per worker. |
646
- | **Full Feature Support** | All Mudis features (TTL, compression, metrics, etc.) work transparently. |
647
- | **Safe & Local** | Communication is limited to the host system’s UNIX socket, ensuring isolation and speed. |
648
-
649
- ![mudis_ipc](design/mudis_ipc.png "Mudis IPC")
650
-
651
- ### Setup (Puma)
652
-
653
- Enable IPC mode by adding the following to your Puma configuration:
654
-
655
- ```ruby
656
- # config/puma.rb
657
- preload_app!
658
-
659
- before_fork do
660
- require "mudis"
661
- require "mudis_server"
662
-
663
- # typical Mudis configuration
664
- Mudis.configure do |c|
665
- c.serializer = JSON
666
- c.compress = true
667
- c.max_value_bytes = 2_000_000
668
- c.hard_memory_limit = true
669
- c.max_bytes = 1_073_741_824
670
- end
671
-
672
- Mudis.start_expiry_thread(interval: 60)
673
- MudisServer.start!
674
-
675
- at_exit { Mudis.stop_expiry_thread }
676
- end
677
-
678
- on_worker_boot do
679
- require "mudis_client"
680
- $mudis = MudisClient.new
681
- require "mudis_proxy" # optionally require the default mudis proxy to invisibly patch Mudis
682
- end
683
- ```
684
-
685
- For more granular control over Mudis, adding a custom Proxy manually to `initializers` (Rails) or `boot` (Hanami) allows seamless use of the API as documented.
686
-
687
- **Do not require `mudis_proxy` if following this method**
688
-
689
- Example custom proxy:
690
-
691
- ```ruby
692
- # config/<<initializers|boot>>/mudis_proxy.rb
693
- unless defined?(MudisServer)
694
- class Mudis
695
- def self.read(*a, **k) = $mudis.read(*a, **k)
696
- def self.write(*a, **k) = $mudis.write(*a, **k)
697
- def self.delete(*a, **k) = $mudis.delete(*a, **k)
698
- def self.fetch(*a, **k, &b) = $mudis.fetch(*a, **k, &b)
699
- def self.metrics = $mudis.metrics
700
- def self.reset_metrics! = $mudis.reset_metrics!
701
- def self.reset! = $mudis.reset!
702
- end
703
-
704
- end
705
- ```
706
-
707
- **Use IPC mode when:**
708
-
709
- - Running Rails or Rack apps under Puma cluster or multi-process background job workers.
710
- - You need cache consistency and memory efficiency without standing up Redis.
711
- - You want to preserve Mudis’s observability, configurability, and in-process simplicity at a larger scale.
712
-
713
- ---
714
-
715
- ## Create a Mudis Web Cache Server
716
-
717
- ### Minimal Setup
718
-
719
- - Create a new Rails API app:
720
-
721
- ```bash
722
- rails new mudis-server --api
723
- cd mudis-server
724
- ```
725
-
726
- - Add mudis to your Gemfile
727
- - Create Initializer: `config/initializers/mudis.rb`
728
- - Define routes
729
-
730
- ```ruby
731
- Rails.application.routes.draw do
732
- get "/cache/:key", to: "cache#show"
733
- post "/cache/:key", to: "cache#write"
734
- delete "/cache/:key", to: "cache#delete"
735
- get "/metrics", to: "cache#metrics"
736
- end
737
- ```
738
-
739
- - Create a `cache_controller` (with optional per caller/consumer namespace)
740
-
741
- ```ruby
742
- class CacheController < ApplicationController
743
-
744
- def show
745
- key = params[:key]
746
- ns = params[:namespace]
747
-
748
- value = Mudis.read(key, namespace: ns)
749
- if value.nil?
750
- render json: { error: "not found" }, status: :not_found
751
- else
752
- render json: { value: value }
753
- end
754
- end
755
-
756
- def write
757
- key = params[:key]
758
- ns = params[:namespace]
759
- val = params[:value]
760
- ttl = params[:expires_in]&.to_i
761
-
762
- Mudis.write(key, val, expires_in: ttl, namespace: ns)
763
- render json: { status: "written", key: key }
764
- end
765
-
766
- def delete
767
- key = params[:key]
768
- ns = params[:namespace]
769
-
770
- Mudis.delete(key, namespace: ns)
771
- render json: { status: "deleted" }
772
- end
773
-
774
- def metrics
775
- render json: Mudis.metrics
776
- end
777
- end
778
- ```
779
-
780
- - Test it
781
-
782
- ```bash
783
- curl http://localhost:3000/cache/foo
784
- curl -X POST http://localhost:3000/cache/foo -d 'value=bar&expires_in=60'
785
- curl http://localhost:3000/metrics
786
-
787
- # Write with namespace
788
- curl -X POST "http://localhost:3000/cache/foo?namespace=orders" \
789
- -d "value=123&expires_in=60"
790
-
791
- # Read from namespace
792
- curl "http://localhost:3000/cache/foo?namespace=orders"
793
-
794
- # Delete from namespace
795
- curl -X DELETE "http://localhost:3000/cache/foo?namespace=orders"
796
-
797
- ```
798
-
799
- ---
800
-
801
- ## Project Philosophy
802
-
803
- Mudis is intended to be a minimal, thread-safe, in-memory cache designed specifically for Ruby applications. It focuses on:
804
-
805
- - In-process caching
806
- - Fine-grained memory and namespace control
807
- - Observability and testing friendliness
808
- - Minimal external dependencies
809
- - Configurability without complexity
810
-
811
- The primary use cases are:
812
-
813
- - Per-service application caches
814
- - Short-lived local caching inside background jobs or API layers
815
-
816
- 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.,
817
-
818
- - mudis-web-cache-server – expose Mudis via HTTP, web sockets, hooks, etc
819
- - mudis-broker – distributed key routing layer for coordinating multiple Mudis nodes
820
- - mudis-activejob-store – adapter for using Mudis in job queues or retry buffers
821
-
822
- ---
823
-
824
- ## Roadmap
825
-
826
- #### API Enhancements
827
-
828
- - [x] bulk_read(keys, namespace:): Batch retrieval of multiple keys with a single method call
829
-
830
- #### Safety & Policy Controls
831
-
832
- - [x] max_ttl: Enforce a global upper bound on expires_in to prevent excessively long-lived keys
833
- - [x] default_ttl: Provide a fallback TTL when one is not specified
834
-
835
- #### Debugging
836
-
837
- - [x] clear_namespace(namespace): Remove all keys in a namespace in one call
838
-
839
- #### Refactor Mudis
840
-
841
- - [x] Review Mudis for improved readability and reduce complexity in top-level functions
842
- - [x] Enhanced guards
843
- - [ ] Review for functionality gaps and enhance as needed
844
-
845
- ---
846
-
847
- ## License
848
-
849
- MIT License © kiebor81
850
-
851
- ---
852
-
853
- ## Contributing
854
-
855
- See [contributor's guide](CONTRIBUTING.md)
856
-
857
- ---
858
-
859
- ## Contact
860
-
861
- For issues, suggestions, or feedback, please open a GitHub issue
862
-
863
- ---
1
+ ![mudis_signet](design/mudis.png "Mudis")
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/mudis.svg?icon=si%3Arubygems&refresh=1&cachebust=0)](https://badge.fury.io/rb/mudis)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+
6
+ **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.
7
+
8
+ 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.
9
+
10
+ Alternatively, Mudis can be upscaled with higher sharding and resources in a dedicated Rails app to provide a [Mudis Web Cache Server](#create-a-mudis-web-cache-server).
11
+
12
+ Mudis also works naturally in Hanami because it’s a pure Ruby in-memory cache. Whether used as a singleton within a process or via IPC in cluster mode, it preserves Hanami’s lightweight and modular architecture.
13
+
14
+ ---
15
+
16
+ ## Table of Contents
17
+
18
+ - [Why Another Caching Gem?](#why-another-caching-gem)
19
+ - [Similar Gems](#similar-gems)
20
+ - [Feature / Function Comparison](#feature--function-comparison)
21
+ - [Design](#design)
22
+ - [Internal Structure and Behaviour](#internal-structure-and-behaviour)
23
+ - [Write - Read - Eviction](#write---read---eviction)
24
+ - [Cache Key Lifecycle](#cache-key-lifecycle)
25
+ - [Features](#features)
26
+ - [Installation](#installation)
27
+ - [Configuration (Rails)](#configuration-rails)
28
+ - [Configuration (Hanami)](#configuration-hanami)
29
+ - [Basic Usage](#basic-usage)
30
+ - [Developer Utilities](#developer-utilities)
31
+ - [`Mudis.reset!`](#mudisreset)
32
+ - [`Mudis.reset_metrics!`](#mudisreset_metrics)
33
+ - [`Mudis.least_touched`](#mudisleast_touched)
34
+ - [`Mudis.keys(namespace:)`](#mudiskeysnamespace)
35
+ - [`Mudis.clear_namespace(namespace:)`](#mudisclear_namespacenamespace)
36
+ - [Rails Service Integration](#rails-service-integration)
37
+ - [Metrics](#metrics)
38
+ - [Advanced Configuration](#advanced-configuration)
39
+ - [Benchmarks](#benchmarks)
40
+ - [Graceful Shutdown](#graceful-shutdown)
41
+ - [Known Limitations](#known-limitations)
42
+ - [Inter-Process Caching (IPC Mode)](#inter-process-caching-ipc-mode)
43
+ - [Overview](#overview)
44
+ - [Setup (Puma)](#setup-puma)
45
+ - [Create a Mudis Web Cache Server](#create-a-mudis-web-cache-server)
46
+ - [Minimal Setup](#minimal-setup)
47
+ - [Project Philosophy](#project-philosophy)
48
+ - [Roadmap](#roadmap)
49
+
50
+ ---
51
+
52
+ ### Why another Caching Gem?
53
+
54
+ There are plenty out there, in various states of maintenance and in many shapes and sizes. So why on earth do we need another? I needed a drop-in replacement for Kredis, and the reason I was interested in using Kredis was for the simplified API and keyed management it gave me in extension to Redis. But what I didn't really need was Redis. I needed an observable, fast, simple, easy to use, flexible and highly configurable, thread-safe and high performant caching system which didn't require too many dependencies or standing up additional services. So, Mudis was born. In its most rudimentary state it was extremely useful in my project, which was an API gateway connecting into mutliple micro-services and a wide selection of APIs. The majority of the data was cold and produced by repeat expensive queries across several domains. Mudis allowed for me to minimize the footprint of the gateway, and improve end user experience, and increase performance. So, yeah, there's a lot of these gems out there, but none which really met all my needs. I decided to provide Mudis for anyone else. If you use it, I'd be interested to know how and whether you got any benefit.
55
+
56
+ #### Similar Gems
57
+
58
+ - [FastCache](https://github.com/swoop-inc/fast_cache)
59
+ - [EasyCache](https://github.com/malvads/easycache)
60
+ - [MiniCache](https://github.com/derrickreimer/mini_cache)
61
+ - [Zache](https://github.com/yegor256/zache)
62
+
63
+ #### Feature / Function Comparison
64
+
65
+ | **Feature** | **Mudis** | **MemoryStore** (`Rails.cache`) | **FastCache** | **Zache** | **EasyCache** | **MiniCache** |
66
+ | -------------------------------------- | ---------------- | ------------------------------- | -------------- | ------------- | ------------- | -------------- |
67
+ | **LRU eviction strategy** | ✅ Per-bucket | ✅ Global | ✅ Global | ❌ | ❌ | ✅ Simplistic |
68
+ | **TTL expiry support** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
69
+ | **Background expiry cleanup thread** | ✅ | ❌ (only on access) | ❌ | ✅ | ❌ | ❌ |
70
+ | **Thread safety** | ✅ Bucketed | ⚠️ Global lock | ✅ Fine-grained | ✅ | ⚠️ | ⚠️ |
71
+ | **Sharding (buckets)** | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
72
+ | **Custom serializers** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
73
+ | **Compression (Zlib)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
74
+ | **Hard memory cap** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
75
+ | **Max value size enforcement** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
76
+ | **Metrics (hits, misses, evictions)** | ✅ | ⚠️ Partial | ❌ | ❌ | ❌ | ❌ |
77
+ | **Fetch/update pattern** | ✅ Full | ✅ Standard | ⚠️ Partial | ✅ Basic | ✅ Basic | ✅ Basic |
78
+ | **Namespacing** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
79
+ | **Replace (if exists)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
80
+ | **Clear/delete method** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
81
+ | **Key inspection with metadata** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
82
+ | **Concurrency model** | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
83
+ | **Maintenance level** | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ |
84
+ | **Suitable for APIs or microservices** | ✅ | ⚠️ Limited | ✅ | ⚠️ Small apps | ⚠️ Small apps | ❌ |
85
+ | **Inter-process Caching** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
86
+
87
+ ---
88
+
89
+ ## Design
90
+
91
+ #### Internal Structure and Behaviour
92
+
93
+ ![mudis_flow](design/mudis_obj.png "Mudis Internals")
94
+
95
+ #### Write - Read - Eviction
96
+
97
+ ![mudis_flow](design/mudis_flow.png "Write - Read - Eviction")
98
+
99
+ #### Cache Key Lifecycle
100
+
101
+ ![mudis_lru](design/mudis_lru.png "Mudis Cache Key Lifecycle")
102
+
103
+ ---
104
+
105
+ ## Features
106
+
107
+ - **Thread-safe**: Uses per-bucket mutexes for high concurrency.
108
+ - **Sharded**: Buckets data across multiple internal stores to minimize lock contention.
109
+ - **LRU Eviction**: Automatically evicts least recently used items as memory fills up.
110
+ - **Expiry Support**: Optional TTL per key with background cleanup thread.
111
+ - **Compression**: Optional Zlib compression for large values.
112
+ - **Metrics**: Tracks hits, misses, and evictions.
113
+ - **IPC Mode**: Shared cross-process caching for multi-process aplications.
114
+
115
+ ---
116
+
117
+ ## Installation
118
+
119
+ Add this line to your Gemfile:
120
+
121
+ ```ruby
122
+ gem 'mudis'
123
+ ```
124
+
125
+ Or install it manually:
126
+
127
+ ```bash
128
+ gem install mudis
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Configuration (Rails)
134
+
135
+ In your Rails app, create an initializer:
136
+
137
+ ```ruby
138
+ # config/initializers/mudis.rb
139
+ Mudis.configure do |c|
140
+ c.serializer = JSON # or Marshal | Oj
141
+ c.compress = true # Compress values using Zlib
142
+ c.max_value_bytes = 2_000_000 # Reject values > 2MB
143
+ c.hard_memory_limit = true # enforce hard memory limits
144
+ c.max_bytes = 1_073_741_824 # set maximum cache size
145
+ end
146
+
147
+ Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
148
+
149
+ at_exit do
150
+ Mudis.stop_expiry_thread
151
+ end
152
+ ```
153
+
154
+ Or with direct setters:
155
+
156
+ ```ruby
157
+ Mudis.serializer = JSON # or Marshal | Oj
158
+ Mudis.compress = true # Compress values using Zlib
159
+ Mudis.max_value_bytes = 2_000_000 # Reject values > 2MB
160
+ Mudis.hard_memory_limit = true # enforce hard memory limits
161
+ Mudis.max_bytes = 1_073_741_824 # set maximum cache size
162
+
163
+ Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
164
+
165
+ ## set at exit hook
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Configuration (Hanami)
171
+
172
+ Mudis integrates seamlessly with [Hanami](https://hanamirb.org) applications. It provides the same configuration flexibility and thread-safe caching capabilities as in Rails, with minimal setup differences.
173
+
174
+ Create a boot file:
175
+
176
+ ```ruby
177
+ # config/boot/mudis.rb
178
+ require "mudis"
179
+
180
+ Mudis.configure do |c|
181
+ c.serializer = JSON
182
+ c.compress = true
183
+ c.max_value_bytes = 2_000_000
184
+ c.hard_memory_limit = true
185
+ c.max_bytes = 1_073_741_824
186
+ end
187
+
188
+ Mudis.start_expiry_thread(interval: 60)
189
+
190
+ at_exit { Mudis.stop_expiry_thread }
191
+ ```
192
+
193
+ Then require it from `config/app.rb`:
194
+
195
+ ```ruby
196
+ # config/app.rb
197
+ require_relative "./boot/mudis"
198
+
199
+ module MyApp
200
+ class App < Hanami::App
201
+ end
202
+ end
203
+ ```
204
+
205
+ This ensures Mudis is initialized and available globally throughout your Hanami application in the same as it would in Rails.
206
+
207
+ ---
208
+
209
+ ### Using Mudis with Hanami’s Dependency Container
210
+
211
+ You can register Mudis as a dependency in the Hanami container to access it via dependency injection:
212
+
213
+ ```ruby
214
+ # config/container.rb
215
+ require "mudis"
216
+
217
+ MyApp::Container.register(:cache, Mudis)
218
+ ```
219
+
220
+ Then use it inside your actions, repositories, or services:
221
+
222
+ ```ruby
223
+ # apps/main/actions/users/show.rb
224
+ module Main
225
+ module Actions
226
+ module Users
227
+ class Show < Main::Action
228
+ include Deps[cache: "cache"]
229
+
230
+ def handle(req, res)
231
+ res[:user] = cache.fetch("user:#{req.params[:id]}", expires_in: 60) do
232
+ UserRepo.new.find(req.params[:id])
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
239
+ ```
240
+
241
+ ---
242
+
243
+ ## Basic Usage
244
+
245
+ ```ruby
246
+ require 'mudis'
247
+
248
+ # Write a value with optional TTL
249
+ Mudis.write('user:123', { name: 'Alice' }, expires_in: 600)
250
+
251
+ # Read it back
252
+ Mudis.read('user:123') # => { "name" => "Alice" }
253
+
254
+ # Check if it exists
255
+ Mudis.exists?('user:123') # => true
256
+
257
+ # Atomically update
258
+ Mudis.update('user:123') { |data| data.merge(age: 30) }
259
+
260
+ # Delete a key
261
+ Mudis.delete('user:123')
262
+ ```
263
+
264
+ ### Developer Utilities
265
+
266
+ Mudis provides utility methods to help with test environments, console debugging, and dev tool resets.
267
+
268
+ #### `Mudis.reset!`
269
+ Clears the internal cache state. Including all keys, memory tracking, and metrics. Also stops the expiry thread.
270
+
271
+ ```ruby
272
+ Mudis.write("foo", "bar")
273
+ Mudis.reset!
274
+ Mudis.read("foo") # => nil
275
+ ```
276
+
277
+ - Wipe all buckets (@stores, @lru_nodes, @current_bytes)
278
+ - Reset all metrics (:hits, :misses, :evictions, :rejected)
279
+ - Stop any running background expiry thread
280
+
281
+ #### `Mudis.reset_metrics!`
282
+
283
+ Clears only the metric counters and preserves all cached values.
284
+
285
+ ```ruby
286
+ Mudis.write("key", "value")
287
+ Mudis.read("key") # => "value"
288
+ Mudis.metrics # => { hits: 1, misses: 0, ... }
289
+
290
+ Mudis.reset_metrics!
291
+ Mudis.metrics # => { hits: 0, misses: 0, ... }
292
+ Mudis.read("key") # => "value" (still cached)
293
+ ```
294
+
295
+ #### `Mudis.least_touched`
296
+
297
+ 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.
298
+
299
+ Each result includes the full key and its access count.
300
+
301
+ ```ruby
302
+ Mudis.least_touched
303
+ # => [["foo", 0], ["user:42", 1], ["product:123", 2], ...]
304
+
305
+ Mudis.least_touched(5)
306
+ # => returns top 5 least accessed keys
307
+ ```
308
+
309
+ #### `Mudis.keys(namespace:)`
310
+
311
+ Returns all keys for a given namespace.
312
+
313
+ ```ruby
314
+ Mudis.write("u1", "alpha", namespace: "users")
315
+ Mudis.write("u2", "beta", namespace: "users")
316
+
317
+ Mudis.keys(namespace: "users")
318
+ # => ["u1", "u2"]
319
+
320
+ ```
321
+
322
+ #### `Mudis.clear_namespace(namespace:)`
323
+
324
+ Deletes all keys within a namespace.
325
+
326
+ ```ruby
327
+ Mudis.clear_namespace("users")
328
+ Mudis.read("u1", namespace: "users") # => nil
329
+ ```
330
+
331
+ ---
332
+
333
+ ## Rails Service Integration
334
+
335
+ For simplified or transient use in a controller, you can wrap your cache logic in a reusable thin class:
336
+
337
+ ```ruby
338
+ class MudisService
339
+ attr_reader :cache_key, :namespace
340
+
341
+ # Initialize the service with a cache key and optional namespace
342
+ #
343
+ # @param cache_key [String] the base key to use
344
+ # @param namespace [String, nil] optional logical namespace
345
+ def initialize(cache_key, namespace: nil)
346
+ @cache_key = cache_key
347
+ @namespace = namespace
348
+ end
349
+
350
+ # Write a value to the cache
351
+ #
352
+ # @param data [Object] the value to cache
353
+ # @param expires_in [Integer, nil] optional TTL in seconds
354
+ def write(data, expires_in: nil)
355
+ Mudis.write(cache_key, data, expires_in: expires_in, namespace: namespace)
356
+ end
357
+
358
+ # Read the cached value or return default
359
+ #
360
+ # @param default [Object] fallback value if key is not present
361
+ def read(default: nil)
362
+ Mudis.read(cache_key, namespace: namespace) || default
363
+ end
364
+
365
+ # Update the cached value using a block
366
+ #
367
+ # @yieldparam current [Object] the current value
368
+ # @yieldreturn [Object] the updated value
369
+ def update
370
+ Mudis.update(cache_key, namespace: namespace) { |current| yield(current) }
371
+ end
372
+
373
+ # Delete the key from cache
374
+ def delete
375
+ Mudis.delete(cache_key, namespace: namespace)
376
+ end
377
+
378
+ # Return true if the key exists in cache
379
+ def exists?
380
+ Mudis.exists?(cache_key, namespace: namespace)
381
+ end
382
+
383
+ # Fetch from cache or compute and store it
384
+ #
385
+ # @param expires_in [Integer, nil] optional TTL
386
+ # @param force [Boolean] force recomputation
387
+ # @yield return value if key is missing
388
+ def fetch(expires_in: nil, force: false)
389
+ Mudis.fetch(cache_key, expires_in: expires_in, force: force, namespace: namespace) do
390
+ yield
391
+ end
392
+ end
393
+
394
+ # Inspect metadata for the current key
395
+ #
396
+ # @return [Hash, nil] metadata including :expires_at, :created_at, :size_bytes, etc.
397
+ def inspect_meta
398
+ Mudis.inspect(cache_key, namespace: namespace)
399
+ end
400
+ end
401
+
402
+ ```
403
+
404
+ Use it like:
405
+
406
+ ```ruby
407
+ cache = MudisService.new("user:42:profile", namespace: "users")
408
+
409
+ cache.write({ name: "Alice" }, expires_in: 300)
410
+ cache.read # => { "name" => "Alice" }
411
+ cache.exists? # => true
412
+
413
+ cache.update { |data| data.merge(age: 30) }
414
+ cache.fetch(expires_in: 60) { expensive_query }
415
+ cache.inspect_meta # => { key: "users:user:42:profile", ... }
416
+ ```
417
+
418
+ *This pattern can also be applied in Hanami services using the registered Mudis dependency*
419
+
420
+ ---
421
+
422
+ ## Metrics
423
+
424
+ Track cache effectiveness and performance:
425
+
426
+ ```ruby
427
+ Mudis.metrics
428
+ # => {
429
+ # hits: 15,
430
+ # misses: 5,
431
+ # evictions: 3,
432
+ # rejected: 0,
433
+ # total_memory: 45678,
434
+ # least_touched: [
435
+ # ["user:1", 0],
436
+ # ["post:5", 1],
437
+ # ...
438
+ # ],
439
+ # buckets: [
440
+ # { index: 0, keys: 12, memory_bytes: 12345, lru_size: 12 },
441
+ # ...
442
+ # ]
443
+ # }
444
+
445
+ ```
446
+
447
+ Optionally, expose Mudis metrics from a controller or action for remote analysis and monitoring.
448
+
449
+ **Rails:**
450
+
451
+ ```ruby
452
+ class MudisController < ApplicationController
453
+ def metrics
454
+ render json: { mudis: Mudis.metrics }
455
+ end
456
+
457
+ end
458
+ ```
459
+
460
+ **Hanami:**
461
+
462
+ *Mudis Registered in the container*
463
+
464
+ ```ruby
465
+ # apps/main/actions/metrics/show.rb
466
+ module Main::Actions::Metrics
467
+ class Show < Main::Action
468
+ include Deps[cache: "cache"]
469
+
470
+ def handle(*, res)
471
+ res.format = :json
472
+ res.body = { mudis: cache.metrics }.to_json
473
+ end
474
+ end
475
+ end
476
+ ```
477
+
478
+ *Mudis not registered in the container*
479
+
480
+ ```ruby
481
+ # apps/main/actions/metrics/show.rb
482
+ module Main::Actions::Metrics
483
+ class Show < Main::Action
484
+ def handle(*, res)
485
+ res.format = :json
486
+ res.body = { mudis: Mudis.metrics }.to_json
487
+ end
488
+ end
489
+ end
490
+ ```
491
+
492
+ ```ruby
493
+ # config/routes.rb
494
+ module Main
495
+ class Routes < Hanami::Routes
496
+ root { "OK" }
497
+
498
+ get "/metrics", to: "metrics.show"
499
+ end
500
+ end
501
+ ```
502
+
503
+ ---
504
+
505
+ ## Advanced Configuration
506
+
507
+ | Setting | Description | Default |
508
+ |--------------------------|---------------------------------------------|--------------------|
509
+ | `Mudis.serializer` | JSON, Marshal, or Oj | `JSON` |
510
+ | `Mudis.compress` | Enable Zlib compression | `false` |
511
+ | `Mudis.max_value_bytes` | Max allowed size in bytes for a value | `nil` (no limit) |
512
+ | `Mudis.buckets` | Number of cache shards | `32` |
513
+ | `Mudis.start_expiry_thread` | Background TTL cleanup loop (every N sec) | Disabled by default|
514
+ | `Mudis.hard_memory_limit` | Enforce hard memory limits on key size and reject if exceeded | `false`|
515
+ | `Mudis.max_bytes` | Maximum allowed cache size | `1GB`|
516
+ | `Mudis.max_ttl` | Set the maximum permitted TTL | `nil` (no limit) |
517
+ | `Mudis.default_ttl` | Set the default TTL for fallback when none is provided | `nil` |
518
+
519
+ Buckets can also be set using a `MUDIS_BUCKETS` environment variable.
520
+
521
+ When setting `serializer`, be mindful of the below
522
+
523
+ | Serializer | Recommended for |
524
+ | ---------- | ------------------------------------- |
525
+ | `Marshal` | Ruby-only apps, speed-sensitive logic |
526
+ | `JSON` | Cross-language interoperability |
527
+ | `Oj` | API-heavy apps using JSON at scale |
528
+
529
+ ---
530
+
531
+ ## Benchmarks
532
+
533
+ #### Serializer(s)
534
+
535
+ _100000 iterations_
536
+
537
+ | Serializer | Total Time (s) | Ops/sec |
538
+ |----------------|------------|----------------|
539
+ | oj | 0.1342 | 745320 |
540
+ | marshal | 0.3228 | 309824 |
541
+ | json | 0.9035 | 110682 |
542
+ | oj + zlib | 1.8050 | 55401 |
543
+ | marshal + zlib | 1.8057 | 55381 |
544
+ | json + zlib | 2.7949 | 35780 |
545
+
546
+ > If opting for OJ, you will need to install the dependency in your project and configure as needed.
547
+
548
+ #### Mudis vs Rails.cache
549
+
550
+ Mudis is marginally slower than `Rails.cache` by design; it trades raw speed for control, observability, and safety.
551
+
552
+ _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression ON_
553
+
554
+ | Operation | `Rails.cache` | `Mudis` | Delta |
555
+ | --------- | ------------- | ----------- | --------- |
556
+ | Write | 2.139 ms/op | 2.417 ms/op | +0.278 ms |
557
+ | Read | 0.007 ms/op | 0.810 ms/op | +0.803 ms |
558
+
559
+ > 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.
560
+
561
+ #### **Why this overhead exists**
562
+
563
+ Mudis includes features that MemoryStore doesn’t:
564
+
565
+ | Feature | Mudis | Rails.cache (MemoryStore) |
566
+ | ------------------ | ---------------------- | --------------------------- |
567
+ | Per-key TTL expiry | ✅ | ⚠️ on access |
568
+ | True LRU eviction | ✅ | ❌ |
569
+ | Hard memory limits | ✅ | ❌ |
570
+ | Value compression | ✅ | ❌ |
571
+ | Thread safety | ✅ Bucket-level mutexes | ✅ Global mutex |
572
+ | Observability | ✅ | ❌ |
573
+ | Namespacing | ✅ | ❌ Manual scoping |
574
+ | IPC Aware | ✅ (if enabled) | ❌ Requires manual configuration and additional gems |
575
+
576
+ It will be down to the developer to decide if a fraction of a millisecond is worth
577
+
578
+ - Predictable eviction
579
+ - Configurable expiry
580
+ - Memory protection
581
+ - Namespace scoping
582
+ - Real-time metrics for hits, misses, evictions, memory usage
583
+
584
+ _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression OFF (to match MemoryStore default)_
585
+
586
+ | Operation | `Rails.cache` | `Mudis` | Delta |
587
+ | --------- | ------------- | ----------- | ------------- |
588
+ | Write | 2.342 ms/op | 0.501 ms/op | **−1.841 ms** |
589
+ | Read | 0.007 ms/op | 0.011 ms/op | +0.004 ms |
590
+
591
+ With compression disabled, Mudis writes significanty faster and reads are virtually identical. Optimisation and configuration of Mudis will be determined by your individual needs.
592
+
593
+ #### Other Benchmarks
594
+
595
+ _10000 iterations of 512KB, JSON, compression OFF (to match MemoryStore default)_
596
+
597
+ | Operation | `Rails.cache` | `Mudis` | Delta |
598
+ | --------- | ------------- | ----------- | ------------- |
599
+ | Write | 1.291 ms/op | 0.32 ms/op | **−0.971 ms** |
600
+ | Read | 0.011 ms/op | 0.048 ms/op | +0.037 ms |
601
+
602
+ _10000 iterations of 512KB, JSON, compression ON_
603
+
604
+ | Operation | `Rails.cache` | `Mudis` | Delta |
605
+ | --------- | ------------- | ----------- | ------------- |
606
+ | Write | 1.11 ms/op | 1.16 ms/op | +0.05 ms |
607
+ | Read | 0.07 ms/op | 0.563 ms/op | +0.493 ms |
608
+
609
+ ---
610
+
611
+ ## Graceful Shutdown
612
+
613
+ Don’t forget to stop the expiry thread when your app exits:
614
+
615
+ ```ruby
616
+ at_exit { Mudis.stop_expiry_thread }
617
+ ```
618
+
619
+ ---
620
+
621
+ ## Known Limitations
622
+
623
+ - Data is **non-persistent**.
624
+ - Compression introduces CPU overhead.
625
+
626
+ ---
627
+
628
+ ## Inter-Process Caching (IPC Mode)
629
+
630
+ While Mudis was originally designed as an in-process cache, it can also operate as a shared inter-process cache when running in environments that use concurrent processes (such as Puma in cluster mode). This is achieved through a local UNIX socket server that allows all workers to access a single, centralized Mudis instance.
631
+
632
+ ### Overview
633
+
634
+ In IPC mode, Mudis runs as a singleton server within the master process.
635
+
636
+ Each worker connects to that server through a lightweight client (`MudisClient`) using a local UNIX domain socket (default: `/tmp/mudis.sock`).
637
+ All cache operations, e.g., read, write, delete, fetch, etc., are transparently proxied to the master process, which holds the authoritative cache state.
638
+
639
+ This design allows multiple workers to share the same cache without duplicating memory or losing synchronization, while retaining Mudis’ performance, configurability, and thread safety.
640
+
641
+ | **Benefit** | **Description** |
642
+ | --------------------------------- | ---------------------------------------------------------------------------------------- |
643
+ | **Shared Cache Across Processes** | All Puma workers share one Mudis instance via IPC. |
644
+ | **Zero External Dependencies** | No Redis, Memcached, or separate daemon required. |
645
+ | **Memory Efficient** | Cache data stored only once, not duplicated per worker. |
646
+ | **Full Feature Support** | All Mudis features (TTL, compression, metrics, etc.) work transparently. |
647
+ | **Safe & Local** | Communication is limited to the host system’s UNIX socket, ensuring isolation and speed. |
648
+
649
+ ![mudis_ipc](design/mudis_ipc.png "Mudis IPC")
650
+
651
+ ### Setup (Puma)
652
+
653
+ Enable IPC mode by adding the following to your Puma configuration:
654
+
655
+ ```ruby
656
+ # config/puma.rb
657
+ preload_app!
658
+
659
+ before_fork do
660
+ require "mudis"
661
+ require "mudis_server"
662
+
663
+ # typical Mudis configuration
664
+ Mudis.configure do |c|
665
+ c.serializer = JSON
666
+ c.compress = true
667
+ c.max_value_bytes = 2_000_000
668
+ c.hard_memory_limit = true
669
+ c.max_bytes = 1_073_741_824
670
+ end
671
+
672
+ Mudis.start_expiry_thread(interval: 60)
673
+ MudisServer.start!
674
+
675
+ at_exit { Mudis.stop_expiry_thread }
676
+ end
677
+
678
+ on_worker_boot do
679
+ require "mudis_client"
680
+ $mudis = MudisClient.new
681
+ require "mudis_proxy" # optionally require the default mudis proxy to invisibly patch Mudis
682
+ end
683
+ ```
684
+
685
+ For more granular control over Mudis, adding a custom Proxy manually to `initializers` (Rails) or `boot` (Hanami) allows seamless use of the API as documented.
686
+
687
+ **Do not require `mudis_proxy` if following this method**
688
+
689
+ Example custom proxy:
690
+
691
+ ```ruby
692
+ # config/<<initializers|boot>>/mudis_proxy.rb
693
+ if defined?($mudis) && $mudis
694
+ class Mudis
695
+ def self.read(*a, **k) = $mudis.read(*a, **k)
696
+ def self.write(*a, **k) = $mudis.write(*a, **k)
697
+ def self.delete(*a, **k) = $mudis.delete(*a, **k)
698
+ def self.fetch(*a, **k, &b) = $mudis.fetch(*a, **k, &b)
699
+ def self.metrics = $mudis.metrics
700
+ def self.reset_metrics! = $mudis.reset_metrics!
701
+ def self.reset! = $mudis.reset!
702
+ end
703
+
704
+ end
705
+ ```
706
+
707
+ **Use IPC mode when:**
708
+
709
+ - Running Rails or Rack apps under Puma cluster or multi-process background job workers.
710
+ - You need cache consistency and memory efficiency without standing up Redis.
711
+ - You want to preserve Mudis’s observability, configurability, and in-process simplicity at a larger scale.
712
+
713
+ ---
714
+
715
+ ## Create a Mudis Web Cache Server
716
+
717
+ ### Minimal Setup
718
+
719
+ - Create a new Rails API app:
720
+
721
+ ```bash
722
+ rails new mudis-server --api
723
+ cd mudis-server
724
+ ```
725
+
726
+ - Add mudis to your Gemfile
727
+ - Create Initializer: `config/initializers/mudis.rb`
728
+ - Define routes
729
+
730
+ ```ruby
731
+ Rails.application.routes.draw do
732
+ get "/cache/:key", to: "cache#show"
733
+ post "/cache/:key", to: "cache#write"
734
+ delete "/cache/:key", to: "cache#delete"
735
+ get "/metrics", to: "cache#metrics"
736
+ end
737
+ ```
738
+
739
+ - Create a `cache_controller` (with optional per caller/consumer namespace)
740
+
741
+ ```ruby
742
+ class CacheController < ApplicationController
743
+
744
+ def show
745
+ key = params[:key]
746
+ ns = params[:namespace]
747
+
748
+ value = Mudis.read(key, namespace: ns)
749
+ if value.nil?
750
+ render json: { error: "not found" }, status: :not_found
751
+ else
752
+ render json: { value: value }
753
+ end
754
+ end
755
+
756
+ def write
757
+ key = params[:key]
758
+ ns = params[:namespace]
759
+ val = params[:value]
760
+ ttl = params[:expires_in]&.to_i
761
+
762
+ Mudis.write(key, val, expires_in: ttl, namespace: ns)
763
+ render json: { status: "written", key: key }
764
+ end
765
+
766
+ def delete
767
+ key = params[:key]
768
+ ns = params[:namespace]
769
+
770
+ Mudis.delete(key, namespace: ns)
771
+ render json: { status: "deleted" }
772
+ end
773
+
774
+ def metrics
775
+ render json: Mudis.metrics
776
+ end
777
+ end
778
+ ```
779
+
780
+ - Test it
781
+
782
+ ```bash
783
+ curl http://localhost:3000/cache/foo
784
+ curl -X POST http://localhost:3000/cache/foo -d 'value=bar&expires_in=60'
785
+ curl http://localhost:3000/metrics
786
+
787
+ # Write with namespace
788
+ curl -X POST "http://localhost:3000/cache/foo?namespace=orders" \
789
+ -d "value=123&expires_in=60"
790
+
791
+ # Read from namespace
792
+ curl "http://localhost:3000/cache/foo?namespace=orders"
793
+
794
+ # Delete from namespace
795
+ curl -X DELETE "http://localhost:3000/cache/foo?namespace=orders"
796
+
797
+ ```
798
+
799
+ ---
800
+
801
+ ## Project Philosophy
802
+
803
+ Mudis is intended to be a minimal, thread-safe, in-memory cache designed specifically for Ruby applications. It focuses on:
804
+
805
+ - In-process caching
806
+ - Fine-grained memory and namespace control
807
+ - Observability and testing friendliness
808
+ - Minimal external dependencies
809
+ - Configurability without complexity
810
+
811
+ The primary use cases are:
812
+
813
+ - Per-service application caches
814
+ - Short-lived local caching inside background jobs or API layers
815
+
816
+ 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.,
817
+
818
+ - mudis-web-cache-server – expose Mudis via HTTP, web sockets, hooks, etc
819
+ - mudis-broker – distributed key routing layer for coordinating multiple Mudis nodes
820
+ - mudis-activejob-store – adapter for using Mudis in job queues or retry buffers
821
+
822
+ ---
823
+
824
+ ## Roadmap
825
+
826
+ #### API Enhancements
827
+
828
+ - [x] bulk_read(keys, namespace:): Batch retrieval of multiple keys with a single method call
829
+
830
+ #### Safety & Policy Controls
831
+
832
+ - [x] max_ttl: Enforce a global upper bound on expires_in to prevent excessively long-lived keys
833
+ - [x] default_ttl: Provide a fallback TTL when one is not specified
834
+
835
+ #### Debugging
836
+
837
+ - [x] clear_namespace(namespace): Remove all keys in a namespace in one call
838
+
839
+ #### Refactor Mudis
840
+
841
+ - [x] Review Mudis for improved readability and reduce complexity in top-level functions
842
+ - [x] Enhanced guards
843
+ - [ ] Review for functionality gaps and enhance as needed
844
+
845
+ ---
846
+
847
+ ## License
848
+
849
+ MIT License © kiebor81
850
+
851
+ ---
852
+
853
+ ## Contributing
854
+
855
+ See [contributor's guide](CONTRIBUTING.md)
856
+
857
+ ---
858
+
859
+ ## Contact
860
+
861
+ For issues, suggestions, or feedback, please open a GitHub issue
862
+
863
+ ---