mudis 0.1.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 +7 -0
- data/README.md +228 -0
- data/lib/mudis/version.rb +3 -0
- data/lib/mudis.rb +273 -0
- data/sig/mudis.rbs +28 -0
- data/spec/mudis_spec.rb +120 -0
- metadata +64 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1586fbcdac3ae237f88954090971beca8be02f3892f36c25363403d2ac80f34f
|
4
|
+
data.tar.gz: b12179ea11d6f51a3049d3eaa4428648fc088ecb6ade607bbe832227ceb64678
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: bff8cda9a3f627f6c94b3f40b737365603ab51fd6edd8e845f058bed517ccfa86a4ad61465fb4b807540525b779586cfbbe77eccd8a16ab59afc69aa2f7cd43a
|
7
|
+
data.tar.gz: 544c25fee1a7346e8c0725ee5e83d5764f3028d76a261666ecdc0d3291c2bccf5a52cf07713d480750cb149e12c52201a2da2a6de0faa8f191ea45b7ba81d47b
|
data/README.md
ADDED
@@ -0,0 +1,228 @@
|
|
1
|
+

|
2
|
+
|
3
|
+
**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
|
+
|
5
|
+
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
|
+
|
7
|
+
---
|
8
|
+
|
9
|
+
## Design
|
10
|
+
|
11
|
+
#### Write - Read - Eviction
|
12
|
+
|
13
|
+

|
14
|
+
|
15
|
+
#### Cache Key Lifecycle
|
16
|
+
|
17
|
+

|
18
|
+
|
19
|
+
---
|
20
|
+
|
21
|
+
## Features
|
22
|
+
|
23
|
+
- **Thread-safe**: Uses per-bucket mutexes for high concurrency.
|
24
|
+
- **Sharded**: Buckets data across multiple internal stores to minimize lock contention.
|
25
|
+
- **LRU Eviction**: Automatically evicts least recently used items as memory fills up.
|
26
|
+
- **Expiry Support**: Optional TTL per key with background cleanup thread.
|
27
|
+
- **Compression**: Optional Zlib compression for large values.
|
28
|
+
- **Metrics**: Tracks hits, misses, and evictions.
|
29
|
+
|
30
|
+
---
|
31
|
+
|
32
|
+
## Installation
|
33
|
+
|
34
|
+
Add this line to your Gemfile:
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
gem 'mudis'
|
38
|
+
```
|
39
|
+
|
40
|
+
Or install it manually:
|
41
|
+
|
42
|
+
```bash
|
43
|
+
gem install mudis
|
44
|
+
```
|
45
|
+
|
46
|
+
---
|
47
|
+
|
48
|
+
## Configuration (Rails)
|
49
|
+
|
50
|
+
In your Rails app, create an initializer:
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
# config/initializers/mudis.rb
|
54
|
+
|
55
|
+
Mudis.serializer = JSON # or Marshal | Oj
|
56
|
+
Mudis.compress = true # Compress values using Zlib
|
57
|
+
Mudis.max_value_bytes = 2_000_000 # Reject values > 2MB
|
58
|
+
Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
|
59
|
+
|
60
|
+
at_exit do
|
61
|
+
Mudis.stop_expiry_thread
|
62
|
+
end
|
63
|
+
```
|
64
|
+
|
65
|
+
> If your `lib/` folder isn't eager loaded, explicitly `require 'mudis'` in this file.
|
66
|
+
|
67
|
+
---
|
68
|
+
|
69
|
+
## Basic Usage
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
require 'mudis'
|
73
|
+
|
74
|
+
# Write a value with optional TTL
|
75
|
+
Mudis.write('user:123', { name: 'Alice' }, expires_in: 600)
|
76
|
+
|
77
|
+
# Read it back
|
78
|
+
Mudis.read('user:123') # => { "name" => "Alice" }
|
79
|
+
|
80
|
+
# Check if it exists
|
81
|
+
Mudis.exists?('user:123') # => true
|
82
|
+
|
83
|
+
# Atomically update
|
84
|
+
Mudis.update('user:123') { |data| data.merge(age: 30) }
|
85
|
+
|
86
|
+
# Delete a key
|
87
|
+
Mudis.delete('user:123')
|
88
|
+
```
|
89
|
+
|
90
|
+
---
|
91
|
+
|
92
|
+
## Rails Service Integration
|
93
|
+
|
94
|
+
For simplified or transient use in a controller, you can wrap your cache logic in a reusable thin class:
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
class MudisService
|
98
|
+
attr_reader :cache_key
|
99
|
+
|
100
|
+
def initialize(cache_key)
|
101
|
+
@cache_key = cache_key
|
102
|
+
end
|
103
|
+
|
104
|
+
def write(data, expires_in: nil)
|
105
|
+
Mudis.write(cache_key, data, expires_in: expires_in)
|
106
|
+
end
|
107
|
+
|
108
|
+
def read(default: nil)
|
109
|
+
Mudis.read(cache_key) || default
|
110
|
+
end
|
111
|
+
|
112
|
+
def update
|
113
|
+
Mudis.update(cache_key) { |current| yield(current) }
|
114
|
+
end
|
115
|
+
|
116
|
+
def delete
|
117
|
+
Mudis.delete(cache_key)
|
118
|
+
end
|
119
|
+
|
120
|
+
def exists?
|
121
|
+
Mudis.exists?(cache_key)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
```
|
125
|
+
|
126
|
+
Use it like:
|
127
|
+
|
128
|
+
```ruby
|
129
|
+
cache = MudisCacheService.new("user:#{current_user.id}")
|
130
|
+
cache.write({ preferences: "dark" }, expires_in: 3600)
|
131
|
+
cache.read # => { "preferences" => "dark" }
|
132
|
+
```
|
133
|
+
|
134
|
+
---
|
135
|
+
|
136
|
+
## Metrics
|
137
|
+
|
138
|
+
Track cache effectiveness and performance:
|
139
|
+
|
140
|
+
```ruby
|
141
|
+
Mudis.metrics
|
142
|
+
# => { hits: 15, misses: 5, evictions: 3 }
|
143
|
+
```
|
144
|
+
|
145
|
+
Optionally, return these metrics from a controller for remote analysis and monitoring if using rails.
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
class MudisController < ApplicationController
|
149
|
+
|
150
|
+
def metrics
|
151
|
+
render json: {
|
152
|
+
mudis_metrics: Mudis.metrics,
|
153
|
+
memory_used_bytes: Mudis.current_memory_bytes,
|
154
|
+
memory_max_bytes: Mudis.max_memory_bytes,
|
155
|
+
keys: Mudis.all_keys.size
|
156
|
+
}
|
157
|
+
end
|
158
|
+
|
159
|
+
end
|
160
|
+
|
161
|
+
```
|
162
|
+
|
163
|
+
---
|
164
|
+
|
165
|
+
## Advanced Configuration
|
166
|
+
|
167
|
+
| Setting | Description | Default |
|
168
|
+
|--------------------------|---------------------------------------------|--------------------|
|
169
|
+
| `Mudis.serializer` | JSON, Marshal, or Oj | `JSON` |
|
170
|
+
| `Mudis.compress` | Enable Zlib compression | `false` |
|
171
|
+
| `Mudis.max_value_bytes` | Max allowed size in bytes for a value | `nil` (no limit) |
|
172
|
+
| `Mudis.buckets` | Number of cache shards (via ENV var) | `32` |
|
173
|
+
| `start_expiry_thread` | Background TTL cleanup loop (every N sec) | Disabled by default|
|
174
|
+
|
175
|
+
To customize the number of buckets, set the `MUDIS_BUCKETS` environment variable.
|
176
|
+
|
177
|
+
---
|
178
|
+
|
179
|
+
## Graceful Shutdown
|
180
|
+
|
181
|
+
Don’t forget to stop the expiry thread when your app exits:
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
at_exit { Mudis.stop_expiry_thread }
|
185
|
+
```
|
186
|
+
|
187
|
+
---
|
188
|
+
|
189
|
+
## Known Limitations
|
190
|
+
|
191
|
+
- Data is **process-local** and **non-persistent**.
|
192
|
+
- Not suitable for cross-process or cross-language use.
|
193
|
+
- Keys are globally scoped (no namespacing by default).
|
194
|
+
- Compression introduces CPU overhead.
|
195
|
+
|
196
|
+
---
|
197
|
+
|
198
|
+
## Roadmap
|
199
|
+
|
200
|
+
- [ ] Namespaced cache keys
|
201
|
+
- [ ] Stats per bucket
|
202
|
+
- [ ] Optional max memory cap per bucket
|
203
|
+
- [ ] Built-in fetch/read-or-write DSL
|
204
|
+
|
205
|
+
---
|
206
|
+
|
207
|
+
## License
|
208
|
+
|
209
|
+
MIT License © kiebor81
|
210
|
+
|
211
|
+
---
|
212
|
+
|
213
|
+
## Contributing
|
214
|
+
|
215
|
+
PRs are welcome! To get started:
|
216
|
+
|
217
|
+
```bash
|
218
|
+
git clone https://github.com/kiebor81/mudis
|
219
|
+
cd mudis
|
220
|
+
bundle install
|
221
|
+
|
222
|
+
```
|
223
|
+
|
224
|
+
---
|
225
|
+
|
226
|
+
## Contact
|
227
|
+
|
228
|
+
For issues, suggestions, or feedback, please open a GitHub issue
|
data/lib/mudis.rb
ADDED
@@ -0,0 +1,273 @@
|
|
1
|
+
# lib/mudis.rb
|
2
|
+
require 'json'
|
3
|
+
require 'thread'
|
4
|
+
require 'zlib'
|
5
|
+
|
6
|
+
# Mudis is a thread-safe, in-memory, sharded, LRU cache with optional compression and expiry.
|
7
|
+
# It is designed for high concurrency and performance within a Ruby application.
|
8
|
+
class Mudis
|
9
|
+
# --- Global Configuration and State ---
|
10
|
+
|
11
|
+
@serializer = JSON # Default serializer (can be changed to Marshal or Oj)
|
12
|
+
@compress = false # Whether to compress values with Zlib
|
13
|
+
@metrics = { hits: 0, misses: 0, evictions: 0 } # Metrics tracking read/write behavior
|
14
|
+
@metrics_mutex = Mutex.new # Mutex for synchronizing access to metrics
|
15
|
+
@max_value_bytes = nil # Optional size cap per value
|
16
|
+
@stop_expiry = false # Signal for stopping expiry thread
|
17
|
+
|
18
|
+
class << self
|
19
|
+
attr_accessor :serializer, :compress, :max_value_bytes
|
20
|
+
|
21
|
+
# Returns a snapshot of metrics (thread-safe)
|
22
|
+
def metrics
|
23
|
+
@metrics_mutex.synchronize { @metrics.dup }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Node structure for the LRU doubly-linked list
|
28
|
+
class LRUNode
|
29
|
+
attr_accessor :key, :prev, :next
|
30
|
+
def initialize(key)
|
31
|
+
@key = key
|
32
|
+
@prev = nil
|
33
|
+
@next = nil
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Number of cache buckets (shards). Default: 32
|
38
|
+
def self.buckets
|
39
|
+
@buckets ||= (ENV['MUDIS_BUCKETS']&.to_i || 32)
|
40
|
+
end
|
41
|
+
|
42
|
+
# --- Internal Structures ---
|
43
|
+
|
44
|
+
@stores = Array.new(buckets) { {} } # Array of hash buckets for storage
|
45
|
+
@mutexes = Array.new(buckets) { Mutex.new } # Per-bucket mutexes
|
46
|
+
@lru_heads = Array.new(buckets) { nil } # Head node for each LRU list
|
47
|
+
@lru_tails = Array.new(buckets) { nil } # Tail node for each LRU list
|
48
|
+
@lru_nodes = Array.new(buckets) { {} } # Map of key => LRU node
|
49
|
+
@current_bytes = Array.new(buckets, 0) # Memory usage per bucket
|
50
|
+
@max_bytes = 1_073_741_824 # 1 GB global max cache size
|
51
|
+
@threshold_bytes = (@max_bytes * 0.9).to_i # Eviction threshold at 90%
|
52
|
+
@expiry_thread = nil # Background thread for expiry cleanup
|
53
|
+
|
54
|
+
class << self
|
55
|
+
# Starts a thread that periodically removes expired entries
|
56
|
+
def start_expiry_thread(interval: 60)
|
57
|
+
return if @expiry_thread&.alive?
|
58
|
+
|
59
|
+
@stop_expiry = false
|
60
|
+
@expiry_thread = Thread.new do
|
61
|
+
loop do
|
62
|
+
break if @stop_expiry
|
63
|
+
sleep interval
|
64
|
+
cleanup_expired!
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Signals and joins the expiry thread
|
70
|
+
def stop_expiry_thread
|
71
|
+
@stop_expiry = true
|
72
|
+
@expiry_thread&.join
|
73
|
+
@expiry_thread = nil
|
74
|
+
end
|
75
|
+
|
76
|
+
# Computes which bucket a key belongs to
|
77
|
+
def bucket_index(key)
|
78
|
+
key.hash % buckets
|
79
|
+
end
|
80
|
+
|
81
|
+
# Checks if a key exists and is not expired
|
82
|
+
def exists?(key)
|
83
|
+
!!read(key)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Reads and returns the value for a key, updating LRU and metrics
|
87
|
+
def read(key)
|
88
|
+
raw_entry = nil
|
89
|
+
idx = bucket_index(key)
|
90
|
+
mutex = @mutexes[idx]
|
91
|
+
|
92
|
+
mutex.synchronize do
|
93
|
+
raw_entry = @stores[idx][key]
|
94
|
+
if raw_entry && raw_entry[:expires_at] && Time.now > raw_entry[:expires_at]
|
95
|
+
evict_key(idx, key)
|
96
|
+
raw_entry = nil
|
97
|
+
end
|
98
|
+
|
99
|
+
metric(:hits) if raw_entry
|
100
|
+
metric(:misses) unless raw_entry
|
101
|
+
end
|
102
|
+
|
103
|
+
return nil unless raw_entry
|
104
|
+
|
105
|
+
value = decompress_and_deserialize(raw_entry[:value])
|
106
|
+
promote_lru(idx, key)
|
107
|
+
value
|
108
|
+
end
|
109
|
+
|
110
|
+
# Writes a value to the cache with optional expiry and LRU tracking
|
111
|
+
def write(key, value, expires_in: nil)
|
112
|
+
raw = serializer.dump(value)
|
113
|
+
raw = Zlib::Deflate.deflate(raw) if compress
|
114
|
+
size = key.bytesize + raw.bytesize
|
115
|
+
return if max_value_bytes && raw.bytesize > max_value_bytes
|
116
|
+
|
117
|
+
idx = bucket_index(key)
|
118
|
+
mutex = @mutexes[idx]
|
119
|
+
store = @stores[idx]
|
120
|
+
|
121
|
+
mutex.synchronize do
|
122
|
+
evict_key(idx, key) if store[key]
|
123
|
+
|
124
|
+
while @current_bytes[idx] + size > (@threshold_bytes / buckets) && @lru_tails[idx]
|
125
|
+
evict_key(idx, @lru_tails[idx].key)
|
126
|
+
metric(:evictions)
|
127
|
+
end
|
128
|
+
|
129
|
+
store[key] = {
|
130
|
+
value: raw,
|
131
|
+
expires_at: expires_in ? Time.now + expires_in : nil,
|
132
|
+
created_at: Time.now
|
133
|
+
}
|
134
|
+
|
135
|
+
insert_lru(idx, key)
|
136
|
+
@current_bytes[idx] += size
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
# Atomically updates the value for a key using a block
|
141
|
+
def update(key)
|
142
|
+
idx = bucket_index(key)
|
143
|
+
mutex = @mutexes[idx]
|
144
|
+
store = @stores[idx]
|
145
|
+
|
146
|
+
raw_entry = nil
|
147
|
+
mutex.synchronize do
|
148
|
+
raw_entry = store[key]
|
149
|
+
return nil unless raw_entry
|
150
|
+
end
|
151
|
+
|
152
|
+
value = decompress_and_deserialize(raw_entry[:value])
|
153
|
+
new_value = yield(value)
|
154
|
+
new_raw = serializer.dump(new_value)
|
155
|
+
new_raw = Zlib::Deflate.deflate(new_raw) if compress
|
156
|
+
|
157
|
+
mutex.synchronize do
|
158
|
+
old_size = key.bytesize + raw_entry[:value].bytesize
|
159
|
+
new_size = key.bytesize + new_raw.bytesize
|
160
|
+
store[key][:value] = new_raw
|
161
|
+
@current_bytes[idx] += (new_size - old_size)
|
162
|
+
promote_lru(idx, key)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# Deletes a key from the cache
|
167
|
+
def delete(key)
|
168
|
+
idx = bucket_index(key)
|
169
|
+
mutex = @mutexes[idx]
|
170
|
+
|
171
|
+
mutex.synchronize do
|
172
|
+
evict_key(idx, key)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# Removes expired keys across all buckets
|
177
|
+
def cleanup_expired!
|
178
|
+
now = Time.now
|
179
|
+
buckets.times do |idx|
|
180
|
+
mutex = @mutexes[idx]
|
181
|
+
store = @stores[idx]
|
182
|
+
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
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# Returns an array of all cache keys
|
193
|
+
def all_keys
|
194
|
+
keys = []
|
195
|
+
buckets.times do |idx|
|
196
|
+
mutex = @mutexes[idx]
|
197
|
+
store = @stores[idx]
|
198
|
+
mutex.synchronize { keys.concat(store.keys) }
|
199
|
+
end
|
200
|
+
keys
|
201
|
+
end
|
202
|
+
|
203
|
+
# Returns total memory used across all buckets
|
204
|
+
def current_memory_bytes
|
205
|
+
@current_bytes.sum
|
206
|
+
end
|
207
|
+
|
208
|
+
# Returns configured maximum memory allowed
|
209
|
+
def max_memory_bytes
|
210
|
+
@max_bytes
|
211
|
+
end
|
212
|
+
|
213
|
+
private
|
214
|
+
|
215
|
+
# Decompresses and deserializes a raw value
|
216
|
+
def decompress_and_deserialize(raw)
|
217
|
+
val = compress ? Zlib::Inflate.inflate(raw) : raw
|
218
|
+
serializer.load(val)
|
219
|
+
end
|
220
|
+
|
221
|
+
# Thread-safe metric increment
|
222
|
+
def metric(name)
|
223
|
+
@metrics_mutex.synchronize { @metrics[name] += 1 }
|
224
|
+
end
|
225
|
+
|
226
|
+
# Removes a key from storage and LRU
|
227
|
+
def evict_key(idx, key)
|
228
|
+
store = @stores[idx]
|
229
|
+
entry = store.delete(key)
|
230
|
+
return unless entry
|
231
|
+
|
232
|
+
@current_bytes[idx] -= (key.bytesize + entry[:value].bytesize)
|
233
|
+
|
234
|
+
node = @lru_nodes[idx].delete(key)
|
235
|
+
remove_node(idx, node) if node
|
236
|
+
end
|
237
|
+
|
238
|
+
# Inserts a key at the head of the LRU list
|
239
|
+
def insert_lru(idx, key)
|
240
|
+
node = LRUNode.new(key)
|
241
|
+
node.next = @lru_heads[idx]
|
242
|
+
@lru_heads[idx].prev = node if @lru_heads[idx]
|
243
|
+
@lru_heads[idx] = node
|
244
|
+
@lru_tails[idx] ||= node
|
245
|
+
@lru_nodes[idx][key] = node
|
246
|
+
end
|
247
|
+
|
248
|
+
# Promotes a key to the front of the LRU list
|
249
|
+
def promote_lru(idx, key)
|
250
|
+
node = @lru_nodes[idx][key]
|
251
|
+
return unless node && @lru_heads[idx] != node
|
252
|
+
|
253
|
+
remove_node(idx, node)
|
254
|
+
insert_lru(idx, key)
|
255
|
+
end
|
256
|
+
|
257
|
+
# Removes a node from the LRU list
|
258
|
+
def remove_node(idx, node)
|
259
|
+
if node.prev
|
260
|
+
node.prev.next = node.next
|
261
|
+
else
|
262
|
+
@lru_heads[idx] = node.next
|
263
|
+
end
|
264
|
+
|
265
|
+
if node.next
|
266
|
+
node.next.prev = node.prev
|
267
|
+
else
|
268
|
+
@lru_tails[idx] = node.prev
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
data/sig/mudis.rbs
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# sig/mudis.rbs
|
2
|
+
|
3
|
+
class Mudis
|
4
|
+
# Configuration
|
5
|
+
class << self
|
6
|
+
attr_accessor serializer : Object
|
7
|
+
attr_accessor compress : bool
|
8
|
+
attr_accessor max_value_bytes : Integer?
|
9
|
+
end
|
10
|
+
|
11
|
+
# Lifecycle
|
12
|
+
def self.start_expiry_thread: (?interval: Integer) -> void
|
13
|
+
def self.stop_expiry_thread: () -> void
|
14
|
+
|
15
|
+
# Core operations
|
16
|
+
def self.write: (String, untyped, ?expires_in: Integer) -> void
|
17
|
+
def self.read: (String) -> untyped?
|
18
|
+
def self.update: (String) { (untyped) -> untyped } -> void
|
19
|
+
def self.delete: (String) -> void
|
20
|
+
def self.exists?: (String) -> bool
|
21
|
+
|
22
|
+
# Introspection & management
|
23
|
+
def self.metrics: () -> Hash[Symbol, Integer]
|
24
|
+
def self.cleanup_expired!: () -> void
|
25
|
+
def self.all_keys: () -> Array[String]
|
26
|
+
def self.current_memory_bytes: () -> Integer
|
27
|
+
def self.max_memory_bytes: () -> Integer
|
28
|
+
end
|
data/spec/mudis_spec.rb
ADDED
@@ -0,0 +1,120 @@
|
|
1
|
+
# spec/mudis_spec.rb
|
2
|
+
require_relative "spec_helper"
|
3
|
+
|
4
|
+
RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
5
|
+
before(:each) do
|
6
|
+
Mudis.stop_expiry_thread
|
7
|
+
Mudis.instance_variable_set(:@buckets, nil)
|
8
|
+
Mudis.instance_variable_set(:@stores, Array.new(Mudis.buckets) { {} })
|
9
|
+
Mudis.instance_variable_set(:@mutexes, Array.new(Mudis.buckets) { Mutex.new })
|
10
|
+
Mudis.instance_variable_set(:@lru_heads, Array.new(Mudis.buckets) { nil })
|
11
|
+
Mudis.instance_variable_set(:@lru_tails, Array.new(Mudis.buckets) { nil })
|
12
|
+
Mudis.instance_variable_set(:@lru_nodes, Array.new(Mudis.buckets) { {} })
|
13
|
+
Mudis.instance_variable_set(:@current_bytes, Array.new(Mudis.buckets, 0))
|
14
|
+
Mudis.instance_variable_set(:@metrics, { hits: 0, misses: 0, evictions: 0 })
|
15
|
+
Mudis.serializer = JSON
|
16
|
+
Mudis.compress = false
|
17
|
+
Mudis.max_value_bytes = nil
|
18
|
+
end
|
19
|
+
|
20
|
+
describe ".write and .read" do
|
21
|
+
it "writes and reads a value" do
|
22
|
+
Mudis.write("foo", { bar: "baz" })
|
23
|
+
result = Mudis.read("foo")
|
24
|
+
expect(result).to eq({ "bar" => "baz" })
|
25
|
+
end
|
26
|
+
|
27
|
+
it "returns nil for non-existent keys" do
|
28
|
+
expect(Mudis.read("nope")).to be_nil
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
describe ".exists?" do
|
33
|
+
it "returns true if key exists" do
|
34
|
+
Mudis.write("check", [1, 2, 3])
|
35
|
+
expect(Mudis.exists?("check")).to be true
|
36
|
+
end
|
37
|
+
|
38
|
+
it "returns false if key does not exist" do
|
39
|
+
expect(Mudis.exists?("missing")).to be false
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
describe ".delete" do
|
44
|
+
it "deletes a key" do
|
45
|
+
Mudis.write("temp", 42)
|
46
|
+
Mudis.delete("temp")
|
47
|
+
expect(Mudis.read("temp")).to be_nil
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
describe ".update" do
|
52
|
+
it "updates a cached value" do
|
53
|
+
Mudis.write("counter", 5)
|
54
|
+
Mudis.update("counter") { |v| v + 1 }
|
55
|
+
expect(Mudis.read("counter")).to eq(6)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
describe "expiry handling" do
|
60
|
+
it "expires values after specified time" do
|
61
|
+
Mudis.write("short_lived", "gone soon", expires_in: 1)
|
62
|
+
sleep 2
|
63
|
+
expect(Mudis.read("short_lived")).to be_nil
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe ".metrics" do
|
68
|
+
it "tracks hits and misses" do
|
69
|
+
Mudis.write("hit_me", "value")
|
70
|
+
Mudis.read("hit_me") # hit
|
71
|
+
Mudis.read("miss_me") # miss
|
72
|
+
metrics = Mudis.metrics
|
73
|
+
expect(metrics[:hits]).to eq(1)
|
74
|
+
expect(metrics[:misses]).to eq(1)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
describe "LRU eviction" do
|
79
|
+
it "evicts old entries when size limit is reached" do
|
80
|
+
Mudis.stop_expiry_thread
|
81
|
+
|
82
|
+
# Force one bucket
|
83
|
+
Mudis.instance_variable_set(:@buckets, 1)
|
84
|
+
Mudis.instance_variable_set(:@stores, [{}])
|
85
|
+
Mudis.instance_variable_set(:@mutexes, [Mutex.new])
|
86
|
+
Mudis.instance_variable_set(:@lru_heads, [nil])
|
87
|
+
Mudis.instance_variable_set(:@lru_tails, [nil])
|
88
|
+
Mudis.instance_variable_set(:@lru_nodes, [{}])
|
89
|
+
Mudis.instance_variable_set(:@current_bytes, [0])
|
90
|
+
|
91
|
+
# Set very small threshold
|
92
|
+
Mudis.instance_variable_set(:@threshold_bytes, 60)
|
93
|
+
Mudis.max_value_bytes = 100
|
94
|
+
|
95
|
+
big_val1 = "a" * 50
|
96
|
+
big_val2 = "b" * 50
|
97
|
+
|
98
|
+
Mudis.write("a", big_val1)
|
99
|
+
Mudis.write("b", big_val2)
|
100
|
+
|
101
|
+
expect(Mudis.read("a")).to be_nil
|
102
|
+
expect(Mudis.read("b")).not_to be_nil
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
describe ".all_keys" do
|
107
|
+
it "lists all stored keys" do
|
108
|
+
Mudis.write("k1", 1)
|
109
|
+
Mudis.write("k2", 2)
|
110
|
+
expect(Mudis.all_keys).to include("k1", "k2")
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
describe ".current_memory_bytes" do
|
115
|
+
it "returns a non-zero byte count after writes" do
|
116
|
+
Mudis.write("size_test", "a" * 100)
|
117
|
+
expect(Mudis.current_memory_bytes).to be > 0
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
metadata
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mudis
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- kiebor81
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-07-15 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
description: Thread-safe, bucketed, in-process cache for Ruby apps. Drop-in replacement
|
28
|
+
for Kredis in some scenarios.
|
29
|
+
email:
|
30
|
+
executables: []
|
31
|
+
extensions: []
|
32
|
+
extra_rdoc_files:
|
33
|
+
- sig/mudis.rbs
|
34
|
+
files:
|
35
|
+
- README.md
|
36
|
+
- lib/mudis.rb
|
37
|
+
- lib/mudis/version.rb
|
38
|
+
- sig/mudis.rbs
|
39
|
+
- spec/mudis_spec.rb
|
40
|
+
homepage: https://github.com/kiebor81/mudis
|
41
|
+
licenses:
|
42
|
+
- MIT
|
43
|
+
metadata: {}
|
44
|
+
post_install_message:
|
45
|
+
rdoc_options: []
|
46
|
+
require_paths:
|
47
|
+
- lib
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
49
|
+
requirements:
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: '0'
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: '0'
|
58
|
+
requirements: []
|
59
|
+
rubygems_version: 3.5.17
|
60
|
+
signing_key:
|
61
|
+
specification_version: 4
|
62
|
+
summary: A fast in-memory Ruby LRU cache with compression and expiry.
|
63
|
+
test_files:
|
64
|
+
- spec/mudis_spec.rb
|