mudis 0.2.0 → 0.3.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 +81 -16
- data/lib/mudis/version.rb +1 -1
- data/lib/mudis.rb +42 -14
- data/spec/mudis_spec.rb +49 -2
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cdff64ca287bd83eaa37c48afbc375e4303b7d3cc8d368b80c1d996d9ad0e3c0
|
4
|
+
data.tar.gz: 20d5d5fbc3afdd79d5a6d826a4a62419d1af60532e4b2515d4d9b2c0956e6174
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7de9f04b093e0ae1fadbd7f967fdf843aff8b7d530e766454848ac43afae8bce58228f4d8d3a05349d4d4983c7163b9063a31181de9eb0d30ac0fc3cac7da6b3
|
7
|
+
data.tar.gz: '097252131a51b2dc34db4b8da6dc442ccaa01eb8c49334cfdd658bef9321330f7d8c75037e61abce08ffd6491673d92c75f7438419a964b022c6b53749159936'
|
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|

|
2
2
|
|
3
|
-
[](https://
|
3
|
+
[](https://badge.fury.io/rb/mudis)
|
4
4
|
|
5
5
|
**Mudis** is a fast, thread-safe, in-memory, sharded LRU (Least Recently Used) cache for Ruby applications. Inspired by Redis, it provides value serialization, optional compression, per-key expiry, and metric tracking in a lightweight, dependency-free package that lives inside your Ruby process.
|
6
6
|
|
@@ -60,6 +60,7 @@ Mudis.serializer = JSON # or Marshal | Oj
|
|
60
60
|
Mudis.compress = true # Compress values using Zlib
|
61
61
|
Mudis.max_value_bytes = 2_000_000 # Reject values > 2MB
|
62
62
|
Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
|
63
|
+
Mudis.hard_memory_limit = true # enforce hard memory limits
|
63
64
|
|
64
65
|
at_exit do
|
65
66
|
Mudis.stop_expiry_thread
|
@@ -95,44 +96,87 @@ Mudis.delete('user:123')
|
|
95
96
|
|
96
97
|
## Rails Service Integration
|
97
98
|
|
98
|
-
For simplified or transient use in a controller, you can wrap your cache logic in a reusable thin class
|
99
|
+
For simplified or transient use in a controller, you can wrap your cache logic in a reusable thin class:
|
99
100
|
|
100
101
|
```ruby
|
101
102
|
class MudisService
|
102
|
-
attr_reader :cache_key
|
103
|
+
attr_reader :cache_key, :namespace
|
103
104
|
|
104
|
-
|
105
|
+
# Initialize the service with a cache key and optional namespace
|
106
|
+
#
|
107
|
+
# @param cache_key [String] the base key to use
|
108
|
+
# @param namespace [String, nil] optional logical namespace
|
109
|
+
def initialize(cache_key, namespace: nil)
|
105
110
|
@cache_key = cache_key
|
111
|
+
@namespace = namespace
|
106
112
|
end
|
107
113
|
|
114
|
+
# Write a value to the cache
|
115
|
+
#
|
116
|
+
# @param data [Object] the value to cache
|
117
|
+
# @param expires_in [Integer, nil] optional TTL in seconds
|
108
118
|
def write(data, expires_in: nil)
|
109
|
-
Mudis.write(cache_key, data, expires_in: expires_in)
|
119
|
+
Mudis.write(cache_key, data, expires_in: expires_in, namespace: namespace)
|
110
120
|
end
|
111
121
|
|
122
|
+
# Read the cached value or return default
|
123
|
+
#
|
124
|
+
# @param default [Object] fallback value if key is not present
|
112
125
|
def read(default: nil)
|
113
|
-
Mudis.read(cache_key) || default
|
126
|
+
Mudis.read(cache_key, namespace: namespace) || default
|
114
127
|
end
|
115
128
|
|
129
|
+
# Update the cached value using a block
|
130
|
+
#
|
131
|
+
# @yieldparam current [Object] the current value
|
132
|
+
# @yieldreturn [Object] the updated value
|
116
133
|
def update
|
117
|
-
Mudis.update(cache_key) { |current| yield(current) }
|
134
|
+
Mudis.update(cache_key, namespace: namespace) { |current| yield(current) }
|
118
135
|
end
|
119
136
|
|
137
|
+
# Delete the key from cache
|
120
138
|
def delete
|
121
|
-
Mudis.delete(cache_key)
|
139
|
+
Mudis.delete(cache_key, namespace: namespace)
|
122
140
|
end
|
123
141
|
|
142
|
+
# Return true if the key exists in cache
|
124
143
|
def exists?
|
125
|
-
Mudis.exists?(cache_key)
|
144
|
+
Mudis.exists?(cache_key, namespace: namespace)
|
145
|
+
end
|
146
|
+
|
147
|
+
# Fetch from cache or compute and store it
|
148
|
+
#
|
149
|
+
# @param expires_in [Integer, nil] optional TTL
|
150
|
+
# @param force [Boolean] force recomputation
|
151
|
+
# @yield return value if key is missing
|
152
|
+
def fetch(expires_in: nil, force: false)
|
153
|
+
Mudis.fetch(cache_key, expires_in: expires_in, force: force, namespace: namespace) do
|
154
|
+
yield
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
# Inspect metadata for the current key
|
159
|
+
#
|
160
|
+
# @return [Hash, nil] metadata including :expires_at, :created_at, :size_bytes, etc.
|
161
|
+
def inspect_meta
|
162
|
+
Mudis.inspect(cache_key, namespace: namespace)
|
126
163
|
end
|
127
164
|
end
|
165
|
+
|
128
166
|
```
|
129
167
|
|
130
168
|
Use it like:
|
131
169
|
|
132
170
|
```ruby
|
133
|
-
cache = MudisService.new("user
|
134
|
-
|
135
|
-
cache.
|
171
|
+
cache = MudisService.new("user:42:profile", namespace: "users")
|
172
|
+
|
173
|
+
cache.write({ name: "Alice" }, expires_in: 300)
|
174
|
+
cache.read # => { "name" => "Alice" }
|
175
|
+
cache.exists? # => true
|
176
|
+
|
177
|
+
cache.update { |data| data.merge(age: 30) }
|
178
|
+
cache.fetch(expires_in: 60) { expensive_query }
|
179
|
+
cache.inspect_meta # => { key: "users:user:42:profile", ... }
|
136
180
|
```
|
137
181
|
|
138
182
|
---
|
@@ -143,7 +187,7 @@ Track cache effectiveness and performance:
|
|
143
187
|
|
144
188
|
```ruby
|
145
189
|
Mudis.metrics
|
146
|
-
# => { hits: 15, misses: 5, evictions: 3 }
|
190
|
+
# => { hits: 15, misses: 5, evictions: 3, rejections: 0 }
|
147
191
|
```
|
148
192
|
|
149
193
|
Optionally, return these metrics from a controller for remote analysis and monitoring if using rails.
|
@@ -175,9 +219,33 @@ end
|
|
175
219
|
| `Mudis.max_value_bytes` | Max allowed size in bytes for a value | `nil` (no limit) |
|
176
220
|
| `Mudis.buckets` | Number of cache shards (via ENV var) | `32` |
|
177
221
|
| `start_expiry_thread` | Background TTL cleanup loop (every N sec) | Disabled by default|
|
222
|
+
| `hard_memory_limit ` | Enfirce hard memory limits on key size and reject if exceeded | `false`|
|
178
223
|
|
179
224
|
To customize the number of buckets, set the `MUDIS_BUCKETS` environment variable.
|
180
225
|
|
226
|
+
When setting `serializer`, be mindful of the below
|
227
|
+
|
228
|
+
| Serializer | Recommended for |
|
229
|
+
| ---------- | ------------------------------------- |
|
230
|
+
| `Marshal` | Ruby-only apps, speed-sensitive logic |
|
231
|
+
| `JSON` | Cross-language interoperability |
|
232
|
+
| `Oj` | API-heavy apps using JSON at scale |
|
233
|
+
|
234
|
+
#### Benchmarks
|
235
|
+
|
236
|
+
Based on 100000 iterations
|
237
|
+
|
238
|
+
| Serializer | Iterations | Total Time (s) | Ops/sec |
|
239
|
+
|----------------|------------|----------------|---------|
|
240
|
+
| oj | 100000 | 0.1342 | 745320 |
|
241
|
+
| marshal | 100000 | 0.3228 | 309824 |
|
242
|
+
| json | 100000 | 0.9035 | 110682 |
|
243
|
+
| oj + zlib | 100000 | 1.8050 | 55401 |
|
244
|
+
| marshal + zlib | 100000 | 1.8057 | 55381 |
|
245
|
+
| json + zlib | 100000 | 2.7949 | 35780 |
|
246
|
+
|
247
|
+
> If opting for OJ, you will need to install the dependncy in your project and configure as needed.
|
248
|
+
|
181
249
|
---
|
182
250
|
|
183
251
|
## Graceful Shutdown
|
@@ -193,16 +261,13 @@ at_exit { Mudis.stop_expiry_thread }
|
|
193
261
|
## Known Limitations
|
194
262
|
|
195
263
|
- Data is **non-persistent**.
|
196
|
-
- Keys are globally scoped (no namespacing by default). Namespaving must be handled by the caller/consumer using scoped keys.
|
197
264
|
- Compression introduces CPU overhead.
|
198
265
|
|
199
266
|
---
|
200
267
|
|
201
268
|
## Roadmap
|
202
269
|
|
203
|
-
- [ ] Namespaced cache keys
|
204
270
|
- [ ] Stats per bucket
|
205
|
-
- [ ] Optional max memory cap per bucket
|
206
271
|
|
207
272
|
---
|
208
273
|
|
data/lib/mudis/version.rb
CHANGED
data/lib/mudis.rb
CHANGED
@@ -11,13 +11,13 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
11
11
|
|
12
12
|
@serializer = JSON # Default serializer (can be changed to Marshal or Oj)
|
13
13
|
@compress = false # Whether to compress values with Zlib
|
14
|
-
@metrics = { hits: 0, misses: 0, evictions: 0 } # Metrics tracking read/write
|
14
|
+
@metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 } # Metrics tracking read/write behaviour
|
15
15
|
@metrics_mutex = Mutex.new # Mutex for synchronizing access to metrics
|
16
16
|
@max_value_bytes = nil # Optional size cap per value
|
17
17
|
@stop_expiry = false # Signal for stopping expiry thread
|
18
18
|
|
19
19
|
class << self
|
20
|
-
attr_accessor :serializer, :compress, :max_value_bytes
|
20
|
+
attr_accessor :serializer, :compress, :max_value_bytes, :hard_memory_limit
|
21
21
|
|
22
22
|
# Returns a snapshot of metrics (thread-safe)
|
23
23
|
def metrics
|
@@ -52,6 +52,7 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
52
52
|
@max_bytes = 1_073_741_824 # 1 GB global max cache size
|
53
53
|
@threshold_bytes = (@max_bytes * 0.9).to_i # Eviction threshold at 90%
|
54
54
|
@expiry_thread = nil # Background thread for expiry cleanup
|
55
|
+
@hard_memory_limit = false # Whether to enforce hard memory cap
|
55
56
|
|
56
57
|
class << self
|
57
58
|
# Starts a thread that periodically removes expired entries
|
@@ -82,12 +83,14 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
82
83
|
end
|
83
84
|
|
84
85
|
# Checks if a key exists and is not expired
|
85
|
-
def exists?(key)
|
86
|
+
def exists?(key, namespace: nil)
|
87
|
+
key = namespaced_key(key, namespace)
|
86
88
|
!!read(key)
|
87
89
|
end
|
88
90
|
|
89
91
|
# Reads and returns the value for a key, updating LRU and metrics
|
90
|
-
def read(key) # rubocop:disable Metrics/MethodLength
|
92
|
+
def read(key, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
93
|
+
key = namespaced_key(key, namespace)
|
91
94
|
raw_entry = nil
|
92
95
|
idx = bucket_index(key)
|
93
96
|
mutex = @mutexes[idx]
|
@@ -111,12 +114,18 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
111
114
|
end
|
112
115
|
|
113
116
|
# Writes a value to the cache with optional expiry and LRU tracking
|
114
|
-
def write(key, value, expires_in: nil) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize
|
117
|
+
def write(key, value, expires_in: nil, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
|
118
|
+
key = namespaced_key(key, namespace)
|
115
119
|
raw = serializer.dump(value)
|
116
120
|
raw = Zlib::Deflate.deflate(raw) if compress
|
117
121
|
size = key.bytesize + raw.bytesize
|
118
122
|
return if max_value_bytes && raw.bytesize > max_value_bytes
|
119
123
|
|
124
|
+
if hard_memory_limit && current_memory_bytes + size > max_memory_bytes
|
125
|
+
metric(:rejected)
|
126
|
+
return
|
127
|
+
end
|
128
|
+
|
120
129
|
idx = bucket_index(key)
|
121
130
|
mutex = @mutexes[idx]
|
122
131
|
store = @stores[idx]
|
@@ -141,7 +150,8 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
141
150
|
end
|
142
151
|
|
143
152
|
# Atomically updates the value for a key using a block
|
144
|
-
def update(key) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
153
|
+
def update(key, namespace: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
154
|
+
key = namespaced_key(key, namespace)
|
145
155
|
idx = bucket_index(key)
|
146
156
|
mutex = @mutexes[idx]
|
147
157
|
store = @stores[idx]
|
@@ -167,7 +177,8 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
167
177
|
end
|
168
178
|
|
169
179
|
# Deletes a key from the cache
|
170
|
-
def delete(key)
|
180
|
+
def delete(key, namespace: nil)
|
181
|
+
key = namespaced_key(key, namespace)
|
171
182
|
idx = bucket_index(key)
|
172
183
|
mutex = @mutexes[idx]
|
173
184
|
|
@@ -180,7 +191,8 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
180
191
|
# The block is executed to generate the value if it doesn't exist
|
181
192
|
# Optionally accepts an expiration time
|
182
193
|
# If force is true, it always fetches and writes the value
|
183
|
-
def fetch(key, expires_in: nil, force: false)
|
194
|
+
def fetch(key, expires_in: nil, force: false, namespace: nil)
|
195
|
+
key = namespaced_key(key, namespace)
|
184
196
|
unless force
|
185
197
|
cached = read(key)
|
186
198
|
return cached if cached
|
@@ -194,22 +206,23 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
194
206
|
# Clears a specific key from the cache, a semantic synonym for delete
|
195
207
|
# This method is provided for clarity in usage
|
196
208
|
# It behaves the same as delete
|
197
|
-
def clear(key)
|
198
|
-
delete(key)
|
209
|
+
def clear(key, namespace: nil)
|
210
|
+
delete(key, namespace: namespace)
|
199
211
|
end
|
200
212
|
|
201
213
|
# Replaces the value for a key if it exists, otherwise does nothing
|
202
214
|
# This is useful for updating values without needing to check existence first
|
203
215
|
# It will write the new value and update the expiration if provided
|
204
216
|
# 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)
|
217
|
+
def replace(key, value, expires_in: nil, namespace: nil)
|
218
|
+
return unless exists?(key, namespace: namespace)
|
207
219
|
|
208
|
-
write(key, value, expires_in: expires_in)
|
220
|
+
write(key, value, expires_in: expires_in, namespace: namespace)
|
209
221
|
end
|
210
222
|
|
211
223
|
# Inspects a key and returns all meta data for it
|
212
|
-
def inspect(key) # rubocop:disable Metrics/MethodLength
|
224
|
+
def inspect(key, namespace: nil) # rubocop:disable Metrics/MethodLength
|
225
|
+
key = namespaced_key(key, namespace)
|
213
226
|
idx = bucket_index(key)
|
214
227
|
store = @stores[idx]
|
215
228
|
mutex = @mutexes[idx]
|
@@ -264,6 +277,15 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
264
277
|
@max_bytes
|
265
278
|
end
|
266
279
|
|
280
|
+
# Executes a block with a specific namespace, restoring the old namespace afterwards
|
281
|
+
def with_namespace(namespace)
|
282
|
+
old_ns = Thread.current[:mudis_namespace]
|
283
|
+
Thread.current[:mudis_namespace] = namespace
|
284
|
+
yield
|
285
|
+
ensure
|
286
|
+
Thread.current[:mudis_namespace] = old_ns
|
287
|
+
end
|
288
|
+
|
267
289
|
private
|
268
290
|
|
269
291
|
# Decompresses and deserializes a raw value
|
@@ -322,5 +344,11 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
322
344
|
@lru_tails[idx] = node.prev
|
323
345
|
end
|
324
346
|
end
|
347
|
+
|
348
|
+
# Namespaces a key with an optional namespace
|
349
|
+
def namespaced_key(key, namespace = nil)
|
350
|
+
ns = namespace || Thread.current[:mudis_namespace]
|
351
|
+
ns ? "#{ns}:#{key}" : key
|
352
|
+
end
|
325
353
|
end
|
326
354
|
end
|
data/spec/mudis_spec.rb
CHANGED
@@ -12,7 +12,7 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
12
12
|
Mudis.instance_variable_set(:@lru_tails, Array.new(Mudis.buckets) { nil })
|
13
13
|
Mudis.instance_variable_set(:@lru_nodes, Array.new(Mudis.buckets) { {} })
|
14
14
|
Mudis.instance_variable_set(:@current_bytes, Array.new(Mudis.buckets, 0))
|
15
|
-
Mudis.instance_variable_set(:@metrics, { hits: 0, misses: 0, evictions: 0 })
|
15
|
+
Mudis.instance_variable_set(:@metrics, { hits: 0, misses: 0, evictions: 0, rejected: 0 })
|
16
16
|
Mudis.serializer = JSON
|
17
17
|
Mudis.compress = false
|
18
18
|
Mudis.max_value_bytes = nil
|
@@ -114,6 +114,24 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
114
114
|
end
|
115
115
|
end
|
116
116
|
|
117
|
+
describe "namespacing" do
|
118
|
+
it "uses thread-local namespace in block" do
|
119
|
+
Mudis.with_namespace("test") do
|
120
|
+
Mudis.write("foo", "bar")
|
121
|
+
end
|
122
|
+
expect(Mudis.read("foo", namespace: "test")).to eq("bar")
|
123
|
+
expect(Mudis.read("foo")).to be_nil
|
124
|
+
end
|
125
|
+
|
126
|
+
it "supports explicit namespace override" do
|
127
|
+
Mudis.write("x", 1, namespace: "alpha")
|
128
|
+
Mudis.write("x", 2, namespace: "beta")
|
129
|
+
expect(Mudis.read("x", namespace: "alpha")).to eq(1)
|
130
|
+
expect(Mudis.read("x", namespace: "beta")).to eq(2)
|
131
|
+
expect(Mudis.read("x")).to be_nil
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
117
135
|
describe "expiry handling" do
|
118
136
|
it "expires values after specified time" do
|
119
137
|
Mudis.write("short_lived", "gone soon", expires_in: 1)
|
@@ -133,6 +151,35 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
133
151
|
end
|
134
152
|
end
|
135
153
|
|
154
|
+
describe "memory guards" do
|
155
|
+
before do
|
156
|
+
Mudis.stop_expiry_thread
|
157
|
+
Mudis.instance_variable_set(:@buckets, 1)
|
158
|
+
Mudis.instance_variable_set(:@stores, [{}])
|
159
|
+
Mudis.instance_variable_set(:@mutexes, [Mutex.new])
|
160
|
+
Mudis.instance_variable_set(:@lru_heads, [nil])
|
161
|
+
Mudis.instance_variable_set(:@lru_tails, [nil])
|
162
|
+
Mudis.instance_variable_set(:@lru_nodes, [{}])
|
163
|
+
Mudis.instance_variable_set(:@current_bytes, [0])
|
164
|
+
|
165
|
+
Mudis.max_value_bytes = nil
|
166
|
+
Mudis.instance_variable_set(:@threshold_bytes, 1_000_000) # optional
|
167
|
+
Mudis.hard_memory_limit = true
|
168
|
+
Mudis.instance_variable_set(:@max_bytes, 100) # artificially low
|
169
|
+
end
|
170
|
+
|
171
|
+
it "rejects writes that exceed max memory" do
|
172
|
+
big_value = "a" * 90
|
173
|
+
Mudis.write("a", big_value)
|
174
|
+
expect(Mudis.read("a")).to eq(big_value)
|
175
|
+
|
176
|
+
big_value_2 = "b" * 90 # rubocop:disable Naming/VariableNumber
|
177
|
+
Mudis.write("b", big_value_2)
|
178
|
+
expect(Mudis.read("b")).to be_nil
|
179
|
+
expect(Mudis.metrics[:rejected]).to be > 0
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
136
183
|
describe "LRU eviction" do
|
137
184
|
it "evicts old entries when size limit is reached" do
|
138
185
|
Mudis.stop_expiry_thread
|
@@ -145,7 +192,7 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
145
192
|
Mudis.instance_variable_set(:@lru_tails, [nil])
|
146
193
|
Mudis.instance_variable_set(:@lru_nodes, [{}])
|
147
194
|
Mudis.instance_variable_set(:@current_bytes, [0])
|
148
|
-
|
195
|
+
Mudis.hard_memory_limit = false
|
149
196
|
# Set very small threshold
|
150
197
|
Mudis.instance_variable_set(:@threshold_bytes, 60)
|
151
198
|
Mudis.max_value_bytes = 100
|