mudis 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +8 -6
- data/lib/mudis/version.rb +1 -1
- data/lib/mudis.rb +68 -15
- data/sig/mudis.rbs +11 -0
- data/spec/mudis_spec.rb +59 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 05a589eca123a045badb19d25119bbc9e51c48e6afec22c490615f656c73f65f
|
4
|
+
data.tar.gz: 478553d02b5c62f08d9194355136b0e284c257d47184ba74d79c2d9a26553460
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bbae5238c153bfc4d29e0efd795c01e6c473fd17a69b28e145dc4fe8ada0faa286fce117cb53a0eaba0df8c67560c08814eb15305fe5bf01e405751b56a6f218
|
7
|
+
data.tar.gz: 1807351fac67cc35c66dc898264807b9542871c2e708baaa2e3fc91bd28b9cda1913257a9083e3115da341b836bd7ce3ca15ef23aae8cf998da4fbc65e6f0e6d
|
data/README.md
CHANGED
@@ -1,9 +1,13 @@
|
|
1
1
|

|
2
2
|
|
3
|
+
[](https://rubygems.org/gems/mudis)
|
4
|
+
|
3
5
|
**Mudis** is a fast, thread-safe, in-memory, sharded LRU (Least Recently Used) cache for Ruby applications. Inspired by Redis, it provides value serialization, optional compression, per-key expiry, and metric tracking in a lightweight, dependency-free package that lives inside your Ruby process.
|
4
6
|
|
5
7
|
It’s ideal for scenarios where performance and process-local caching are critical, and where a full Redis setup is overkill or otherwise not possible.
|
6
8
|
|
9
|
+
Alternatively, Mudis can be upscaled with higher sharding and resources in a dedicated rails app to provide a Mudis server.
|
10
|
+
|
7
11
|
---
|
8
12
|
|
9
13
|
## Design
|
@@ -91,7 +95,7 @@ Mudis.delete('user:123')
|
|
91
95
|
|
92
96
|
## Rails Service Integration
|
93
97
|
|
94
|
-
For simplified or transient use in a controller, you can wrap your cache logic in a reusable thin class:
|
98
|
+
For simplified or transient use in a controller, you can wrap your cache logic in a reusable thin class (TODO: add more useful abstraction once DSL and namespacing is introduced):
|
95
99
|
|
96
100
|
```ruby
|
97
101
|
class MudisService
|
@@ -126,7 +130,7 @@ end
|
|
126
130
|
Use it like:
|
127
131
|
|
128
132
|
```ruby
|
129
|
-
cache =
|
133
|
+
cache = MudisService.new("user:#{current_user.id}")
|
130
134
|
cache.write({ preferences: "dark" }, expires_in: 3600)
|
131
135
|
cache.read # => { "preferences" => "dark" }
|
132
136
|
```
|
@@ -188,9 +192,8 @@ at_exit { Mudis.stop_expiry_thread }
|
|
188
192
|
|
189
193
|
## Known Limitations
|
190
194
|
|
191
|
-
- Data is **
|
192
|
-
-
|
193
|
-
- Keys are globally scoped (no namespacing by default).
|
195
|
+
- Data is **non-persistent**.
|
196
|
+
- Keys are globally scoped (no namespacing by default). Namespaving must be handled by the caller/consumer using scoped keys.
|
194
197
|
- Compression introduces CPU overhead.
|
195
198
|
|
196
199
|
---
|
@@ -200,7 +203,6 @@ at_exit { Mudis.stop_expiry_thread }
|
|
200
203
|
- [ ] Namespaced cache keys
|
201
204
|
- [ ] Stats per bucket
|
202
205
|
- [ ] Optional max memory cap per bucket
|
203
|
-
- [ ] Built-in fetch/read-or-write DSL
|
204
206
|
|
205
207
|
---
|
206
208
|
|
data/lib/mudis/version.rb
CHANGED
data/lib/mudis.rb
CHANGED
@@ -1,11 +1,12 @@
|
|
1
1
|
# lib/mudis.rb
|
2
|
-
|
3
|
-
require
|
4
|
-
require
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "thread" # rubocop:disable Lint/RedundantRequireStatement
|
5
|
+
require "zlib"
|
5
6
|
|
6
7
|
# Mudis is a thread-safe, in-memory, sharded, LRU cache with optional compression and expiry.
|
7
8
|
# It is designed for high concurrency and performance within a Ruby application.
|
8
|
-
class Mudis
|
9
|
+
class Mudis # rubocop:disable Metrics/ClassLength
|
9
10
|
# --- Global Configuration and State ---
|
10
11
|
|
11
12
|
@serializer = JSON # Default serializer (can be changed to Marshal or Oj)
|
@@ -27,6 +28,7 @@ class Mudis
|
|
27
28
|
# Node structure for the LRU doubly-linked list
|
28
29
|
class LRUNode
|
29
30
|
attr_accessor :key, :prev, :next
|
31
|
+
|
30
32
|
def initialize(key)
|
31
33
|
@key = key
|
32
34
|
@prev = nil
|
@@ -36,7 +38,7 @@ class Mudis
|
|
36
38
|
|
37
39
|
# Number of cache buckets (shards). Default: 32
|
38
40
|
def self.buckets
|
39
|
-
@buckets ||= (ENV[
|
41
|
+
@buckets ||= (ENV["MUDIS_BUCKETS"]&.to_i || 32) # rubocop:disable Style/RedundantParentheses
|
40
42
|
end
|
41
43
|
|
42
44
|
# --- Internal Structures ---
|
@@ -48,8 +50,8 @@ class Mudis
|
|
48
50
|
@lru_nodes = Array.new(buckets) { {} } # Map of key => LRU node
|
49
51
|
@current_bytes = Array.new(buckets, 0) # Memory usage per bucket
|
50
52
|
@max_bytes = 1_073_741_824 # 1 GB global max cache size
|
51
|
-
@threshold_bytes = (@max_bytes * 0.9).to_i
|
52
|
-
@expiry_thread = nil
|
53
|
+
@threshold_bytes = (@max_bytes * 0.9).to_i # Eviction threshold at 90%
|
54
|
+
@expiry_thread = nil # Background thread for expiry cleanup
|
53
55
|
|
54
56
|
class << self
|
55
57
|
# Starts a thread that periodically removes expired entries
|
@@ -60,6 +62,7 @@ class Mudis
|
|
60
62
|
@expiry_thread = Thread.new do
|
61
63
|
loop do
|
62
64
|
break if @stop_expiry
|
65
|
+
|
63
66
|
sleep interval
|
64
67
|
cleanup_expired!
|
65
68
|
end
|
@@ -84,7 +87,7 @@ class Mudis
|
|
84
87
|
end
|
85
88
|
|
86
89
|
# Reads and returns the value for a key, updating LRU and metrics
|
87
|
-
def read(key)
|
90
|
+
def read(key) # rubocop:disable Metrics/MethodLength
|
88
91
|
raw_entry = nil
|
89
92
|
idx = bucket_index(key)
|
90
93
|
mutex = @mutexes[idx]
|
@@ -108,7 +111,7 @@ class Mudis
|
|
108
111
|
end
|
109
112
|
|
110
113
|
# Writes a value to the cache with optional expiry and LRU tracking
|
111
|
-
def write(key, value, expires_in: nil)
|
114
|
+
def write(key, value, expires_in: nil) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize
|
112
115
|
raw = serializer.dump(value)
|
113
116
|
raw = Zlib::Deflate.deflate(raw) if compress
|
114
117
|
size = key.bytesize + raw.bytesize
|
@@ -138,7 +141,7 @@ class Mudis
|
|
138
141
|
end
|
139
142
|
|
140
143
|
# Atomically updates the value for a key using a block
|
141
|
-
def update(key)
|
144
|
+
def update(key) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
142
145
|
idx = bucket_index(key)
|
143
146
|
mutex = @mutexes[idx]
|
144
147
|
store = @stores[idx]
|
@@ -173,6 +176,59 @@ class Mudis
|
|
173
176
|
end
|
174
177
|
end
|
175
178
|
|
179
|
+
# Fetches a value for a key, writing it if not present or expired
|
180
|
+
# The block is executed to generate the value if it doesn't exist
|
181
|
+
# Optionally accepts an expiration time
|
182
|
+
# If force is true, it always fetches and writes the value
|
183
|
+
def fetch(key, expires_in: nil, force: false)
|
184
|
+
unless force
|
185
|
+
cached = read(key)
|
186
|
+
return cached if cached
|
187
|
+
end
|
188
|
+
|
189
|
+
value = yield
|
190
|
+
write(key, value, expires_in: expires_in)
|
191
|
+
value
|
192
|
+
end
|
193
|
+
|
194
|
+
# Clears a specific key from the cache, a semantic synonym for delete
|
195
|
+
# This method is provided for clarity in usage
|
196
|
+
# It behaves the same as delete
|
197
|
+
def clear(key)
|
198
|
+
delete(key)
|
199
|
+
end
|
200
|
+
|
201
|
+
# Replaces the value for a key if it exists, otherwise does nothing
|
202
|
+
# This is useful for updating values without needing to check existence first
|
203
|
+
# It will write the new value and update the expiration if provided
|
204
|
+
# If the key does not exist, it will not create a new entry
|
205
|
+
def replace(key, value, expires_in: nil)
|
206
|
+
return unless exists?(key)
|
207
|
+
|
208
|
+
write(key, value, expires_in: expires_in)
|
209
|
+
end
|
210
|
+
|
211
|
+
# Inspects a key and returns all meta data for it
|
212
|
+
def inspect(key) # rubocop:disable Metrics/MethodLength
|
213
|
+
idx = bucket_index(key)
|
214
|
+
store = @stores[idx]
|
215
|
+
mutex = @mutexes[idx]
|
216
|
+
|
217
|
+
mutex.synchronize do
|
218
|
+
entry = store[key]
|
219
|
+
return nil unless entry
|
220
|
+
|
221
|
+
{
|
222
|
+
key: key,
|
223
|
+
bucket: idx,
|
224
|
+
expires_at: entry[:expires_at],
|
225
|
+
created_at: entry[:created_at],
|
226
|
+
size_bytes: key.bytesize + entry[:value].bytesize,
|
227
|
+
compressed: compress
|
228
|
+
}
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
176
232
|
# Removes expired keys across all buckets
|
177
233
|
def cleanup_expired!
|
178
234
|
now = Time.now
|
@@ -180,10 +236,8 @@ class Mudis
|
|
180
236
|
mutex = @mutexes[idx]
|
181
237
|
store = @stores[idx]
|
182
238
|
mutex.synchronize do
|
183
|
-
store.keys.each do |key|
|
184
|
-
if store[key][:expires_at] && now > store[key][:expires_at]
|
185
|
-
evict_key(idx, key)
|
186
|
-
end
|
239
|
+
store.keys.each do |key| # rubocop:disable Style/HashEachMethods
|
240
|
+
evict_key(idx, key) if store[key][:expires_at] && now > store[key][:expires_at]
|
187
241
|
end
|
188
242
|
end
|
189
243
|
end
|
@@ -270,4 +324,3 @@ class Mudis
|
|
270
324
|
end
|
271
325
|
end
|
272
326
|
end
|
273
|
-
|
data/sig/mudis.rbs
CHANGED
@@ -19,6 +19,17 @@ class Mudis
|
|
19
19
|
def self.delete: (String) -> void
|
20
20
|
def self.exists?: (String) -> bool
|
21
21
|
|
22
|
+
# DSL & Helpers
|
23
|
+
def self.fetch: (
|
24
|
+
String,
|
25
|
+
?expires_in: Integer,
|
26
|
+
?force: bool
|
27
|
+
) { () -> untyped } -> untyped
|
28
|
+
|
29
|
+
def self.clear: (String) -> void
|
30
|
+
def self.replace: (String, untyped, ?expires_in: Integer) -> void
|
31
|
+
def self.inspect: (String) -> Hash[Symbol, untyped]?
|
32
|
+
|
22
33
|
# Introspection & management
|
23
34
|
def self.metrics: () -> Hash[Symbol, Integer]
|
24
35
|
def self.cleanup_expired!: () -> void
|
data/spec/mudis_spec.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
|
-
#
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
2
3
|
require_relative "spec_helper"
|
3
4
|
|
4
5
|
RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
@@ -56,6 +57,63 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
56
57
|
end
|
57
58
|
end
|
58
59
|
|
60
|
+
describe ".fetch" do
|
61
|
+
it "returns cached value if exists" do
|
62
|
+
Mudis.write("k", 123)
|
63
|
+
result = Mudis.fetch("k", expires_in: 60) { 999 } # fix: use keyword arg
|
64
|
+
expect(result).to eq(123)
|
65
|
+
end
|
66
|
+
|
67
|
+
it "writes and returns block result if missing" do
|
68
|
+
Mudis.delete("k")
|
69
|
+
result = Mudis.fetch("k", expires_in: 60) { 999 } # fix
|
70
|
+
expect(result).to eq(999)
|
71
|
+
expect(Mudis.read("k")).to eq(999)
|
72
|
+
end
|
73
|
+
|
74
|
+
it "forces overwrite if force: true" do
|
75
|
+
Mudis.write("k", 100)
|
76
|
+
result = Mudis.fetch("k", force: true) { 200 } # fix
|
77
|
+
expect(result).to eq(200)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
describe ".clear" do
|
82
|
+
it "removes a key from the cache" do
|
83
|
+
Mudis.write("to_clear", 123)
|
84
|
+
expect(Mudis.read("to_clear")).to eq(123)
|
85
|
+
Mudis.clear("to_clear")
|
86
|
+
expect(Mudis.read("to_clear")).to be_nil
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
describe ".replace" do
|
91
|
+
it "replaces value only if key exists" do
|
92
|
+
Mudis.write("to_replace", 100)
|
93
|
+
Mudis.replace("to_replace", 200)
|
94
|
+
expect(Mudis.read("to_replace")).to eq(200)
|
95
|
+
|
96
|
+
Mudis.delete("to_replace")
|
97
|
+
Mudis.replace("to_replace", 300)
|
98
|
+
expect(Mudis.read("to_replace")).to be_nil
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
describe ".inspect" do
|
103
|
+
it "returns metadata for a cached key" do
|
104
|
+
Mudis.write("key1", "abc", expires_in: 60)
|
105
|
+
meta = Mudis.inspect("key1")
|
106
|
+
|
107
|
+
expect(meta).to include(:key, :bucket, :expires_at, :created_at, :size_bytes, :compressed)
|
108
|
+
expect(meta[:key]).to eq("key1")
|
109
|
+
expect(meta[:compressed]).to eq(false)
|
110
|
+
end
|
111
|
+
|
112
|
+
it "returns nil for missing key" do
|
113
|
+
expect(Mudis.inspect("unknown")).to be_nil
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
59
117
|
describe "expiry handling" do
|
60
118
|
it "expires values after specified time" do
|
61
119
|
Mudis.write("short_lived", "gone soon", expires_in: 1)
|
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.
|
4
|
+
version: 0.2.0
|
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-
|
11
|
+
date: 2025-07-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rspec
|