mudis 0.6.0 → 0.7.1

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: aeea820874569fe79af99d53f2a3ae3a7a9b74dc8a2d9dccbfbaa3f4ebab3967
4
- data.tar.gz: 211d8c786790478e861461aab7444bd4bd1342afa07039ef3608f66dbd401cd6
3
+ metadata.gz: c8d2af60ebd6e5423bd7864ccaa02e597d90fa047bdc2f4b766c0772c16d9dd8
4
+ data.tar.gz: 23a3524cca4ee8b520e4984a24950962aaa3ee52543f95634016f8a7cdc45488
5
5
  SHA512:
6
- metadata.gz: b93779c0990a328a3d24f4741b9934833e5524e099e2ddcdd2ae38f297e599f2cd49bd4fbc43d3cd1d1f81d2655220760b2573138ade72b289b785fc718c9bcd
7
- data.tar.gz: bf25460f6237402793327d4bb04aef416d39f240e80350b8428433aa4245036cac0582f75e25b79c6902d21751403be9def2df01b9ebe54536ee39d229571590
6
+ metadata.gz: abc5c28bd8c7bc0b2da2e3f9ca42cbcf458e4d2c3009654c24ee5a17c4b68b447f4f3866916bf8d8abd4b06d3790faca5ed5bc98190008277a1f4489e944d4d1
7
+ data.tar.gz: eaaa49a62a501f12fa5f1ebf46d2acc0c1d42fdaac0538aa015088fc4a9ae05ed18be298bffb4ffe7a7a3c5fae8369cd22b723bd16191cba1ce0cd676a751624
data/README.md CHANGED
@@ -1,12 +1,53 @@
1
1
  ![mudis_signet](design/mudis.png "Mudis")
2
2
 
3
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)
4
5
 
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.
6
7
 
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.
8
9
 
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
+ 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
+ ---
10
51
 
11
52
  ### Why another Caching Gem?
12
53
 
@@ -41,6 +82,7 @@ There are plenty out there, in various states of maintenance and in many shapes
41
82
  | **Concurrency model** | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
42
83
  | **Maintenance level** | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ |
43
84
  | **Suitable for APIs or microservices** | ✅ | ⚠️ Limited | ✅ | ⚠️ Small apps | ⚠️ Small apps | ❌ |
85
+ | **Inter-process Caching** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
44
86
 
45
87
  ---
46
88
 
@@ -68,6 +110,7 @@ There are plenty out there, in various states of maintenance and in many shapes
68
110
  - **Expiry Support**: Optional TTL per key with background cleanup thread.
69
111
  - **Compression**: Optional Zlib compression for large values.
70
112
  - **Metrics**: Tracks hits, misses, and evictions.
113
+ - **IPC Mode**: Shared cross-process caching for multi-process aplications.
71
114
 
72
115
  ---
73
116
 
@@ -124,6 +167,79 @@ Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
124
167
 
125
168
  ---
126
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
+
127
243
  ## Basic Usage
128
244
 
129
245
  ```ruby
@@ -299,6 +415,8 @@ cache.fetch(expires_in: 60) { expensive_query }
299
415
  cache.inspect_meta # => { key: "users:user:42:profile", ... }
300
416
  ```
301
417
 
418
+ *This pattern can also be applied in Hanami services using the registered Mudis dependency*
419
+
302
420
  ---
303
421
 
304
422
  ## Metrics
@@ -326,7 +444,9 @@ Mudis.metrics
326
444
 
327
445
  ```
328
446
 
329
- Optionally, return these metrics from a controller for remote analysis and monitoring if using Rails.
447
+ Optionally, expose Mudis metrics from a controller or action for remote analysis and monitoring.
448
+
449
+ **Rails:**
330
450
 
331
451
  ```ruby
332
452
  class MudisController < ApplicationController
@@ -337,6 +457,49 @@ class MudisController < ApplicationController
337
457
  end
338
458
  ```
339
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
+
340
503
  ---
341
504
 
342
505
  ## Advanced Configuration
@@ -395,7 +558,7 @@ _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression ON
395
558
 
396
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.
397
560
 
398
- ###### **Why this overhead exists**
561
+ #### **Why this overhead exists**
399
562
 
400
563
  Mudis includes features that MemoryStore doesn’t:
401
564
 
@@ -408,6 +571,7 @@ Mudis includes features that MemoryStore doesn’t:
408
571
  | Thread safety | ✅ Bucket-level mutexes | ✅ Global mutex |
409
572
  | Observability | ✅ | ❌ |
410
573
  | Namespacing | ✅ | ❌ Manual scoping |
574
+ | IPC Aware | ✅ (if enabled) | ❌ Requires manual configuration and additional gems |
411
575
 
412
576
  It will be down to the developer to decide if a fraction of a millisecond is worth
413
577
 
@@ -426,6 +590,22 @@ _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression OF
426
590
 
427
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.
428
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
+
429
609
  ---
430
610
 
431
611
  ## Graceful Shutdown
@@ -445,7 +625,92 @@ at_exit { Mudis.stop_expiry_thread }
445
625
 
446
626
  ---
447
627
 
448
- ## Create a Mudis Server
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 the 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
+ ```ruby
690
+ # config/<<initializers|boot>>/mudis_proxy.rb
691
+ unless defined?(MudisServer)
692
+ class Mudis
693
+ def self.read(*a, **k) = $mudis.read(*a, **k)
694
+ def self.write(*a, **k) = $mudis.write(*a, **k)
695
+ def self.delete(*a, **k) = $mudis.delete(*a, **k)
696
+ def self.fetch(*a, **k, &b) = $mudis.fetch(*a, **k, &b)
697
+ def self.metrics = $mudis.metrics
698
+ def self.reset_metrics! = $mudis.reset_metrics!
699
+ def self.reset! = $mudis.reset!
700
+ end
701
+
702
+ end
703
+ ```
704
+
705
+ **Use IPC mode when:**
706
+
707
+ - Running Rails or Rack apps under Puma cluster or multi-process background job workers.
708
+ - You need cache consistency and memory efficiency without standing up Redis.
709
+ - You want to preserve Mudis’s observability, configurability, and in-process simplicity at a larger scale.
710
+
711
+ ---
712
+
713
+ ## Create a Mudis Web Cache Server
449
714
 
450
715
  ### Minimal Setup
451
716
 
@@ -548,7 +813,7 @@ The primary use cases are:
548
813
 
549
814
  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.,
550
815
 
551
- - mudis-server – expose Mudis via HTTP, web sockets, hooks, etc
816
+ - mudis-web-cache-server – expose Mudis via HTTP, web sockets, hooks, etc
552
817
  - mudis-broker – distributed key routing layer for coordinating multiple Mudis nodes
553
818
  - mudis-activejob-store – adapter for using Mudis in job queues or retry buffers
554
819
 
@@ -569,6 +834,12 @@ Mudis is not intended to be a general-purpose, distributed caching platform. You
569
834
 
570
835
  - [x] clear_namespace(namespace): Remove all keys in a namespace in one call
571
836
 
837
+ #### Refactor Mudis
838
+
839
+ - [x] Review Mudis for improved readability and reduce complexity in top-level functions
840
+ - [x] Enhanced guards
841
+ - [ ] Review for functionality gaps and enhance as needed
842
+
572
843
  ---
573
844
 
574
845
  ## License
data/lib/mudis/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- MUDIS_VERSION = "0.6.0"
3
+ MUDIS_VERSION = "0.7.1"
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+
6
+ # thread-safe client for communicating with the MudisServer via UNIX socket.
7
+ class MudisClient
8
+ SOCKET_PATH = "/tmp/mudis.sock"
9
+
10
+ def initialize
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def request(payload) # rubocop:disable Metrics/MethodLength
15
+ @mutex.synchronize do
16
+ UNIXSocket.open(SOCKET_PATH) do |sock|
17
+ sock.puts(JSON.dump(payload))
18
+ response = sock.gets
19
+ res = JSON.parse(response, symbolize_names: true)
20
+ raise res[:error] unless res[:ok] # rubocop:disable Layout/EmptyLineAfterGuardClause
21
+ res[:value]
22
+ end
23
+ rescue Errno::ENOENT
24
+ warn "[MudisClient] Socket missing; master likely not running MudisServer"
25
+ nil
26
+ end
27
+ end
28
+
29
+ def read(key, namespace: nil)
30
+ request(cmd: "read", key: key, namespace: namespace)
31
+ end
32
+
33
+ def write(key, value, expires_in: nil, namespace: nil)
34
+ request(cmd: "write", key: key, value: value, ttl: expires_in, namespace: namespace)
35
+ end
36
+
37
+ def delete(key, namespace: nil)
38
+ request(cmd: "delete", key: key, namespace: namespace)
39
+ end
40
+
41
+ def exists?(key, namespace: nil)
42
+ request(cmd: "exists", key: key, namespace: namespace)
43
+ end
44
+
45
+ def fetch(key, expires_in: nil, namespace: nil)
46
+ val = read(key, namespace: namespace)
47
+ return val if val
48
+
49
+ new_val = yield
50
+ write(key, new_val, expires_in: expires_in, namespace: namespace)
51
+ new_val
52
+ end
53
+
54
+ def metrics
55
+ request(cmd: "metrics")
56
+ end
57
+
58
+ def reset_metrics!
59
+ request(cmd: "reset_metrics")
60
+ end
61
+
62
+ def reset!
63
+ request(cmd: "reset")
64
+ end
65
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional Mudis proxy layer for IPC mode.
4
+ #
5
+ # To enable:
6
+ # require "mudis_proxy"
7
+ #
8
+ # The proxy will forward calls to `$mudis` (an instance of MudisClient)
9
+ # if it is defined, otherwise fallback to standard in-process behaviour.
10
+
11
+ require_relative "mudis"
12
+ require_relative "mudis_server"
13
+ require_relative "mudis_client"
14
+
15
+ if defined?(MudisServer)
16
+ # In the master process — no proxy needed.
17
+ return
18
+ end
19
+
20
+ unless defined?(MudisClient)
21
+ warn "[MudisProxy] MudisClient not loaded — proxy not activated"
22
+ return
23
+ end
24
+
25
+ class << Mudis
26
+ def read(*a, **k) = $mudis.read(*a, **k) # rubocop:disable Naming/MethodParameterName,Style/GlobalVars
27
+ def write(*a, **k) = $mudis.write(*a, **k) # rubocop:disable Naming/MethodParameterName,Style/GlobalVars
28
+ def delete(*a, **k) = $mudis.delete(*a, **k) # rubocop:disable Naming/MethodParameterName,Style/GlobalVars
29
+ def fetch(*a, **k, &b) = $mudis.fetch(*a, **k, &b) # rubocop:disable Naming/MethodParameterName,Style/GlobalVars
30
+ def metrics = $mudis.metrics # rubocop:disable Style/GlobalVars
31
+ def reset_metrics! = $mudis.reset_metrics! # rubocop:disable Style/GlobalVars
32
+ def reset! = $mudis.reset! # rubocop:disable Style/GlobalVars
33
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+ require_relative "mudis"
6
+
7
+ # Simple UNIX socket server for handling Mudis operations via IPC mode
8
+ class MudisServer
9
+ SOCKET_PATH = "/tmp/mudis.sock"
10
+
11
+ def self.start! # rubocop:disable Metrics/MethodLength
12
+ # Clean up old socket if it exists
13
+ File.unlink(SOCKET_PATH) if File.exist?(SOCKET_PATH)
14
+
15
+ server = UNIXServer.new(SOCKET_PATH)
16
+ server.listen(128)
17
+ puts "[MudisServer] Listening on #{SOCKET_PATH}"
18
+
19
+ Thread.new do
20
+ loop do
21
+ client = server.accept
22
+ Thread.new(client) do |sock|
23
+ handle_client(sock)
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def self.handle_client(sock) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/MethodLength
30
+ request_line = sock.gets
31
+ return unless request_line
32
+
33
+ req = JSON.parse(request_line, symbolize_names: true)
34
+ cmd = req[:cmd]
35
+ key = req[:key]
36
+ ns = req[:namespace]
37
+ val = req[:value]
38
+ ttl = req[:ttl]
39
+
40
+ begin
41
+ case cmd
42
+ when "read"
43
+ result = Mudis.read(key, namespace: ns)
44
+ sock.puts(JSON.dump({ ok: true, value: result }))
45
+
46
+ when "write"
47
+ Mudis.write(key, val, expires_in: ttl, namespace: ns)
48
+ sock.puts(JSON.dump({ ok: true }))
49
+
50
+ when "delete"
51
+ Mudis.delete(key, namespace: ns)
52
+ sock.puts(JSON.dump({ ok: true }))
53
+
54
+ when "exists"
55
+ sock.puts(JSON.dump({ ok: true, value: Mudis.exists?(key, namespace: ns) }))
56
+
57
+ when "fetch"
58
+ result = Mudis.fetch(key, expires_in: ttl, namespace: ns) { req[:fallback] }
59
+ sock.puts(JSON.dump({ ok: true, value: result }))
60
+
61
+ when "metrics"
62
+ sock.puts(JSON.dump({ ok: true, value: Mudis.metrics }))
63
+
64
+ when "reset_metrics"
65
+ Mudis.reset_metrics!
66
+ sock.puts(JSON.dump({ ok: true }))
67
+
68
+ when "reset"
69
+ Mudis.reset!
70
+ sock.puts(JSON.dump({ ok: true }))
71
+
72
+ else
73
+ sock.puts(JSON.dump({ ok: false, error: "unknown command: #{cmd}" }))
74
+ end
75
+ rescue StandardError => e
76
+ sock.puts(JSON.dump({ ok: false, error: e.message }))
77
+ ensure
78
+ sock.close
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,23 @@
1
+ class MudisClient
2
+ SOCKET_PATH: String
3
+
4
+ def initialize: () -> void
5
+
6
+ def request: (payload: { cmd: String, key?: String, value?: untyped, ttl?: Integer?, namespace?: String? }) -> untyped
7
+
8
+ def read: (key: String, namespace?: String?) -> untyped
9
+
10
+ def write: (key: String, value: untyped, expires_in?: Integer?, namespace?: String?) -> untyped
11
+
12
+ def delete: (key: String, namespace?: String?) -> untyped
13
+
14
+ def exists?: (key: String, namespace?: String?) -> bool
15
+
16
+ def fetch: (key: String, expires_in?: Integer?, namespace?: String?, &block: { () -> untyped }) -> untyped
17
+
18
+ def metrics: () -> { reads: Integer, writes: Integer, deletes: Integer, exists: Integer }
19
+
20
+ def reset_metrics!: () -> void
21
+
22
+ def reset!: () -> void
23
+ end
@@ -0,0 +1,7 @@
1
+ class MudisServer
2
+ SOCKET_PATH: String
3
+
4
+ def self.start!: () -> void
5
+
6
+ def self.handle_client: (sock: UNIXSocket) -> void
7
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spec_helper"
4
+
5
+ RSpec.describe MudisClient do # rubocop:disable Metrics/BlockLength
6
+ let(:socket_path) { "/tmp/mudis.sock" }
7
+ let(:mock_socket) { instance_double(UNIXSocket) }
8
+ let(:client) { MudisClient.new }
9
+
10
+ around do |example|
11
+ ClimateControl.modify("SOCKET_PATH" => socket_path) do
12
+ example.run
13
+ end
14
+ end
15
+
16
+ before do
17
+ allow(UNIXSocket).to receive(:open).and_yield(mock_socket)
18
+ end
19
+
20
+ describe "#read" do
21
+ it "sends a read command and returns the value" do
22
+ payload = { cmd: "read", key: "test_key", namespace: nil }
23
+ response = { ok: true, value: "test_value" }.to_json
24
+
25
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
26
+ expect(mock_socket).to receive(:gets).and_return(response)
27
+
28
+ expect(client.read("test_key")).to eq("test_value")
29
+ end
30
+ end
31
+
32
+ describe "#write" do
33
+ it "sends a write command and returns the value" do
34
+ payload = { cmd: "write", key: "test_key", value: "test_value", ttl: nil, namespace: nil }
35
+ response = { ok: true, value: nil }.to_json
36
+
37
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
38
+ expect(mock_socket).to receive(:gets).and_return(response)
39
+
40
+ expect(client.write("test_key", "test_value")).to be_nil
41
+ end
42
+ end
43
+
44
+ describe "#delete" do
45
+ it "sends a delete command and returns the value" do
46
+ payload = { cmd: "delete", key: "test_key", namespace: nil }
47
+ response = { ok: true, value: nil }.to_json
48
+
49
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
50
+ expect(mock_socket).to receive(:gets).and_return(response)
51
+
52
+ expect(client.delete("test_key")).to be_nil
53
+ end
54
+ end
55
+
56
+ describe "#exists?" do
57
+ it "sends an exists command and returns true" do
58
+ payload = { cmd: "exists", key: "test_key", namespace: nil }
59
+ response = { ok: true, value: true }.to_json
60
+
61
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
62
+ expect(mock_socket).to receive(:gets).and_return(response)
63
+
64
+ expect(client.exists?("test_key")).to eq(true)
65
+ end
66
+ end
67
+
68
+ describe "#fetch" do
69
+ it "fetches an existing value or writes a new one" do
70
+ read_response = { ok: true, value: nil }.to_json
71
+ write_payload = { cmd: "write", key: "test_key", value: "new_value", ttl: nil, namespace: nil }
72
+ write_response = { ok: true, value: nil }.to_json
73
+
74
+ expect(mock_socket).to receive(:puts).with({ cmd: "read", key: "test_key", namespace: nil }.to_json)
75
+ expect(mock_socket).to receive(:gets).and_return(read_response)
76
+ expect(mock_socket).to receive(:puts).with(write_payload.to_json)
77
+ expect(mock_socket).to receive(:gets).and_return(write_response)
78
+
79
+ result = client.fetch("test_key") { "new_value" } # rubocop:disable Style/RedundantFetchBlock
80
+ expect(result).to eq("new_value")
81
+ end
82
+ end
83
+
84
+ describe "#metrics" do
85
+ it "sends a metrics command and returns the metrics" do
86
+ payload = { cmd: "metrics" }
87
+ response = { ok: true, value: { reads: 10, writes: 5 } }.to_json
88
+
89
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
90
+ expect(mock_socket).to receive(:gets).and_return(response)
91
+
92
+ expect(client.metrics).to eq({ reads: 10, writes: 5 })
93
+ end
94
+ end
95
+
96
+ describe "#reset_metrics!" do
97
+ it "sends a reset_metrics command" do
98
+ payload = { cmd: "reset_metrics" }
99
+ response = { ok: true, value: nil }.to_json
100
+
101
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
102
+ expect(mock_socket).to receive(:gets).and_return(response)
103
+
104
+ expect(client.reset_metrics!).to be_nil
105
+ end
106
+ end
107
+
108
+ describe "#reset!" do
109
+ it "sends a reset command" do
110
+ payload = { cmd: "reset" }
111
+ response = { ok: true, value: nil }.to_json
112
+
113
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
114
+ expect(mock_socket).to receive(:gets).and_return(response)
115
+
116
+ expect(client.reset!).to be_nil
117
+ end
118
+ end
119
+
120
+ describe "error handling" do
121
+ it "warns when the socket is missing" do
122
+ allow(UNIXSocket).to receive(:open).and_raise(Errno::ENOENT)
123
+
124
+ expect { client.read("test_key") }.to output(/Socket missing/).to_stderr
125
+ expect(client.read("test_key")).to be_nil
126
+ end
127
+
128
+ it "raises an error when the server returns an error" do
129
+ response = { ok: false, error: "Something went wrong" }.to_json
130
+
131
+ expect(mock_socket).to receive(:puts)
132
+ expect(mock_socket).to receive(:gets).and_return(response)
133
+
134
+ expect { client.read("test_key") }.to raise_error("Something went wrong")
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+
6
+ require_relative "spec_helper"
7
+
8
+ RSpec.describe MudisServer do # rubocop:disable Metrics/BlockLength
9
+ let(:socket_path) { MudisServer::SOCKET_PATH }
10
+
11
+ before do
12
+ allow(Mudis).to receive(:read).and_return("mock_value")
13
+ allow(Mudis).to receive(:write)
14
+ allow(Mudis).to receive(:delete)
15
+ allow(Mudis).to receive(:exists?).and_return(true)
16
+ allow(Mudis).to receive(:fetch).and_return("mock_fetched_value")
17
+ allow(Mudis).to receive(:metrics).and_return({ reads: 1, writes: 1 })
18
+ allow(Mudis).to receive(:reset_metrics!)
19
+ allow(Mudis).to receive(:reset!)
20
+
21
+ # Start the server in a separate thread
22
+ Thread.new { MudisServer.start! }
23
+ sleep 0.1 # Allow the server to start
24
+ end
25
+
26
+ after do
27
+ File.unlink(socket_path) if File.exist?(socket_path)
28
+ end
29
+
30
+ def send_request(request)
31
+ UNIXSocket.open(socket_path) do |sock|
32
+ sock.puts(JSON.dump(request))
33
+ JSON.parse(sock.gets, symbolize_names: true)
34
+ end
35
+ end
36
+
37
+ it "handles the 'read' command" do
38
+ response = send_request({ cmd: "read", key: "test_key", namespace: "test_ns" })
39
+ expect(response).to eq({ ok: true, value: "mock_value" })
40
+ expect(Mudis).to have_received(:read).with("test_key", namespace: "test_ns")
41
+ end
42
+
43
+ it "handles the 'write' command" do
44
+ response = send_request({ cmd: "write", key: "test_key", value: "test_value", ttl: 60, namespace: "test_ns" })
45
+ expect(response).to eq({ ok: true })
46
+ expect(Mudis).to have_received(:write).with("test_key", "test_value", expires_in: 60, namespace: "test_ns")
47
+ end
48
+
49
+ it "handles the 'delete' command" do
50
+ response = send_request({ cmd: "delete", key: "test_key", namespace: "test_ns" })
51
+ expect(response).to eq({ ok: true })
52
+ expect(Mudis).to have_received(:delete).with("test_key", namespace: "test_ns")
53
+ end
54
+
55
+ it "handles the 'exists' command" do
56
+ response = send_request({ cmd: "exists", key: "test_key", namespace: "test_ns" })
57
+ expect(response).to eq({ ok: true, value: true })
58
+ expect(Mudis).to have_received(:exists?).with("test_key", namespace: "test_ns")
59
+ end
60
+
61
+ it "handles the 'fetch' command" do
62
+ response = send_request({ cmd: "fetch", key: "test_key", ttl: 60, namespace: "test_ns",
63
+ fallback: "fallback_value" })
64
+ expect(response).to eq({ ok: true, value: "mock_fetched_value" })
65
+ expect(Mudis).to have_received(:fetch).with("test_key", expires_in: 60, namespace: "test_ns")
66
+ end
67
+
68
+ it "handles the 'metrics' command" do
69
+ response = send_request({ cmd: "metrics" })
70
+ expect(response).to eq({ ok: true, value: { reads: 1, writes: 1 } })
71
+ expect(Mudis).to have_received(:metrics)
72
+ end
73
+
74
+ it "handles the 'reset_metrics' command" do
75
+ response = send_request({ cmd: "reset_metrics" })
76
+ expect(response).to eq({ ok: true })
77
+ expect(Mudis).to have_received(:reset_metrics!)
78
+ end
79
+
80
+ it "handles the 'reset' command" do
81
+ response = send_request({ cmd: "reset" })
82
+ expect(response).to eq({ ok: true })
83
+ expect(Mudis).to have_received(:reset!)
84
+ end
85
+
86
+ it "handles unknown commands" do
87
+ response = send_request({ cmd: "unknown_command" })
88
+ expect(response).to eq({ ok: false, error: "unknown command: unknown_command" })
89
+ end
90
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mudis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - kiebor81
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-17 00:00:00.000000000 Z
11
+ date: 2025-10-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -46,18 +46,27 @@ executables: []
46
46
  extensions: []
47
47
  extra_rdoc_files:
48
48
  - sig/mudis.rbs
49
+ - sig/mudis_client.rbs
49
50
  - sig/mudis_config.rbs
51
+ - sig/mudis_server.rbs
50
52
  files:
51
53
  - README.md
52
54
  - lib/mudis.rb
53
55
  - lib/mudis/version.rb
56
+ - lib/mudis_client.rb
54
57
  - lib/mudis_config.rb
58
+ - lib/mudis_proxy.rb
59
+ - lib/mudis_server.rb
55
60
  - sig/mudis.rbs
61
+ - sig/mudis_client.rbs
56
62
  - sig/mudis_config.rbs
63
+ - sig/mudis_server.rbs
57
64
  - spec/eviction_spec.rb
58
65
  - spec/guardrails_spec.rb
59
66
  - spec/memory_guard_spec.rb
60
67
  - spec/metrics_spec.rb
68
+ - spec/mudis_client_spec.rb
69
+ - spec/mudis_server_spec.rb
61
70
  - spec/mudis_spec.rb
62
71
  - spec/namespace_spec.rb
63
72
  - spec/reset_spec.rb
@@ -90,6 +99,8 @@ test_files:
90
99
  - spec/guardrails_spec.rb
91
100
  - spec/memory_guard_spec.rb
92
101
  - spec/metrics_spec.rb
102
+ - spec/mudis_client_spec.rb
103
+ - spec/mudis_server_spec.rb
93
104
  - spec/mudis_spec.rb
94
105
  - spec/namespace_spec.rb
95
106
  - spec/reset_spec.rb