mudis 0.1.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 +84 -17
- data/lib/mudis/version.rb +1 -1
- data/lib/mudis.rb +100 -19
- data/sig/mudis.rbs +11 -0
- data/spec/mudis_spec.rb +108 -3
- 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: 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,9 +1,13 @@
|
|
1
1
|

|
2
2
|
|
3
|
+
[](https://badge.fury.io/rb/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
|
@@ -56,6 +60,7 @@ Mudis.serializer = JSON # or Marshal | Oj
|
|
56
60
|
Mudis.compress = true # Compress values using Zlib
|
57
61
|
Mudis.max_value_bytes = 2_000_000 # Reject values > 2MB
|
58
62
|
Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
|
63
|
+
Mudis.hard_memory_limit = true # enforce hard memory limits
|
59
64
|
|
60
65
|
at_exit do
|
61
66
|
Mudis.stop_expiry_thread
|
@@ -95,40 +100,83 @@ For simplified or transient use in a controller, you can wrap your cache logic i
|
|
95
100
|
|
96
101
|
```ruby
|
97
102
|
class MudisService
|
98
|
-
attr_reader :cache_key
|
103
|
+
attr_reader :cache_key, :namespace
|
99
104
|
|
100
|
-
|
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)
|
101
110
|
@cache_key = cache_key
|
111
|
+
@namespace = namespace
|
102
112
|
end
|
103
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
|
104
118
|
def write(data, expires_in: nil)
|
105
|
-
Mudis.write(cache_key, data, expires_in: expires_in)
|
119
|
+
Mudis.write(cache_key, data, expires_in: expires_in, namespace: namespace)
|
106
120
|
end
|
107
121
|
|
122
|
+
# Read the cached value or return default
|
123
|
+
#
|
124
|
+
# @param default [Object] fallback value if key is not present
|
108
125
|
def read(default: nil)
|
109
|
-
Mudis.read(cache_key) || default
|
126
|
+
Mudis.read(cache_key, namespace: namespace) || default
|
110
127
|
end
|
111
128
|
|
129
|
+
# Update the cached value using a block
|
130
|
+
#
|
131
|
+
# @yieldparam current [Object] the current value
|
132
|
+
# @yieldreturn [Object] the updated value
|
112
133
|
def update
|
113
|
-
Mudis.update(cache_key) { |current| yield(current) }
|
134
|
+
Mudis.update(cache_key, namespace: namespace) { |current| yield(current) }
|
114
135
|
end
|
115
136
|
|
137
|
+
# Delete the key from cache
|
116
138
|
def delete
|
117
|
-
Mudis.delete(cache_key)
|
139
|
+
Mudis.delete(cache_key, namespace: namespace)
|
118
140
|
end
|
119
141
|
|
142
|
+
# Return true if the key exists in cache
|
120
143
|
def exists?
|
121
|
-
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)
|
122
163
|
end
|
123
164
|
end
|
165
|
+
|
124
166
|
```
|
125
167
|
|
126
168
|
Use it like:
|
127
169
|
|
128
170
|
```ruby
|
129
|
-
cache =
|
130
|
-
|
131
|
-
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", ... }
|
132
180
|
```
|
133
181
|
|
134
182
|
---
|
@@ -139,7 +187,7 @@ Track cache effectiveness and performance:
|
|
139
187
|
|
140
188
|
```ruby
|
141
189
|
Mudis.metrics
|
142
|
-
# => { hits: 15, misses: 5, evictions: 3 }
|
190
|
+
# => { hits: 15, misses: 5, evictions: 3, rejections: 0 }
|
143
191
|
```
|
144
192
|
|
145
193
|
Optionally, return these metrics from a controller for remote analysis and monitoring if using rails.
|
@@ -171,9 +219,33 @@ end
|
|
171
219
|
| `Mudis.max_value_bytes` | Max allowed size in bytes for a value | `nil` (no limit) |
|
172
220
|
| `Mudis.buckets` | Number of cache shards (via ENV var) | `32` |
|
173
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`|
|
174
223
|
|
175
224
|
To customize the number of buckets, set the `MUDIS_BUCKETS` environment variable.
|
176
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
|
+
|
177
249
|
---
|
178
250
|
|
179
251
|
## Graceful Shutdown
|
@@ -188,19 +260,14 @@ at_exit { Mudis.stop_expiry_thread }
|
|
188
260
|
|
189
261
|
## Known Limitations
|
190
262
|
|
191
|
-
- Data is **
|
192
|
-
- Not suitable for cross-process or cross-language use.
|
193
|
-
- Keys are globally scoped (no namespacing by default).
|
263
|
+
- Data is **non-persistent**.
|
194
264
|
- Compression introduces CPU overhead.
|
195
265
|
|
196
266
|
---
|
197
267
|
|
198
268
|
## Roadmap
|
199
269
|
|
200
|
-
- [ ] Namespaced cache keys
|
201
270
|
- [ ] Stats per bucket
|
202
|
-
- [ ] Optional max memory cap per bucket
|
203
|
-
- [ ] Built-in fetch/read-or-write DSL
|
204
271
|
|
205
272
|
---
|
206
273
|
|
data/lib/mudis/version.rb
CHANGED
data/lib/mudis.rb
CHANGED
@@ -1,22 +1,23 @@
|
|
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)
|
12
13
|
@compress = false # Whether to compress values with Zlib
|
13
|
-
@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
|
14
15
|
@metrics_mutex = Mutex.new # Mutex for synchronizing access to metrics
|
15
16
|
@max_value_bytes = nil # Optional size cap per value
|
16
17
|
@stop_expiry = false # Signal for stopping expiry thread
|
17
18
|
|
18
19
|
class << self
|
19
|
-
attr_accessor :serializer, :compress, :max_value_bytes
|
20
|
+
attr_accessor :serializer, :compress, :max_value_bytes, :hard_memory_limit
|
20
21
|
|
21
22
|
# Returns a snapshot of metrics (thread-safe)
|
22
23
|
def metrics
|
@@ -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,9 @@ 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
|
55
|
+
@hard_memory_limit = false # Whether to enforce hard memory cap
|
53
56
|
|
54
57
|
class << self
|
55
58
|
# Starts a thread that periodically removes expired entries
|
@@ -60,6 +63,7 @@ class Mudis
|
|
60
63
|
@expiry_thread = Thread.new do
|
61
64
|
loop do
|
62
65
|
break if @stop_expiry
|
66
|
+
|
63
67
|
sleep interval
|
64
68
|
cleanup_expired!
|
65
69
|
end
|
@@ -79,12 +83,14 @@ class Mudis
|
|
79
83
|
end
|
80
84
|
|
81
85
|
# Checks if a key exists and is not expired
|
82
|
-
def exists?(key)
|
86
|
+
def exists?(key, namespace: nil)
|
87
|
+
key = namespaced_key(key, namespace)
|
83
88
|
!!read(key)
|
84
89
|
end
|
85
90
|
|
86
91
|
# Reads and returns the value for a key, updating LRU and metrics
|
87
|
-
def read(key)
|
92
|
+
def read(key, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
93
|
+
key = namespaced_key(key, namespace)
|
88
94
|
raw_entry = nil
|
89
95
|
idx = bucket_index(key)
|
90
96
|
mutex = @mutexes[idx]
|
@@ -108,12 +114,18 @@ class Mudis
|
|
108
114
|
end
|
109
115
|
|
110
116
|
# Writes a value to the cache with optional expiry and LRU tracking
|
111
|
-
def write(key, value, expires_in: nil)
|
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)
|
112
119
|
raw = serializer.dump(value)
|
113
120
|
raw = Zlib::Deflate.deflate(raw) if compress
|
114
121
|
size = key.bytesize + raw.bytesize
|
115
122
|
return if max_value_bytes && raw.bytesize > max_value_bytes
|
116
123
|
|
124
|
+
if hard_memory_limit && current_memory_bytes + size > max_memory_bytes
|
125
|
+
metric(:rejected)
|
126
|
+
return
|
127
|
+
end
|
128
|
+
|
117
129
|
idx = bucket_index(key)
|
118
130
|
mutex = @mutexes[idx]
|
119
131
|
store = @stores[idx]
|
@@ -138,7 +150,8 @@ class Mudis
|
|
138
150
|
end
|
139
151
|
|
140
152
|
# Atomically updates the value for a key using a block
|
141
|
-
def update(key)
|
153
|
+
def update(key, namespace: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
154
|
+
key = namespaced_key(key, namespace)
|
142
155
|
idx = bucket_index(key)
|
143
156
|
mutex = @mutexes[idx]
|
144
157
|
store = @stores[idx]
|
@@ -164,7 +177,8 @@ class Mudis
|
|
164
177
|
end
|
165
178
|
|
166
179
|
# Deletes a key from the cache
|
167
|
-
def delete(key)
|
180
|
+
def delete(key, namespace: nil)
|
181
|
+
key = namespaced_key(key, namespace)
|
168
182
|
idx = bucket_index(key)
|
169
183
|
mutex = @mutexes[idx]
|
170
184
|
|
@@ -173,6 +187,61 @@ class Mudis
|
|
173
187
|
end
|
174
188
|
end
|
175
189
|
|
190
|
+
# Fetches a value for a key, writing it if not present or expired
|
191
|
+
# The block is executed to generate the value if it doesn't exist
|
192
|
+
# Optionally accepts an expiration time
|
193
|
+
# If force is true, it always fetches and writes the value
|
194
|
+
def fetch(key, expires_in: nil, force: false, namespace: nil)
|
195
|
+
key = namespaced_key(key, namespace)
|
196
|
+
unless force
|
197
|
+
cached = read(key)
|
198
|
+
return cached if cached
|
199
|
+
end
|
200
|
+
|
201
|
+
value = yield
|
202
|
+
write(key, value, expires_in: expires_in)
|
203
|
+
value
|
204
|
+
end
|
205
|
+
|
206
|
+
# Clears a specific key from the cache, a semantic synonym for delete
|
207
|
+
# This method is provided for clarity in usage
|
208
|
+
# It behaves the same as delete
|
209
|
+
def clear(key, namespace: nil)
|
210
|
+
delete(key, namespace: namespace)
|
211
|
+
end
|
212
|
+
|
213
|
+
# Replaces the value for a key if it exists, otherwise does nothing
|
214
|
+
# This is useful for updating values without needing to check existence first
|
215
|
+
# It will write the new value and update the expiration if provided
|
216
|
+
# If the key does not exist, it will not create a new entry
|
217
|
+
def replace(key, value, expires_in: nil, namespace: nil)
|
218
|
+
return unless exists?(key, namespace: namespace)
|
219
|
+
|
220
|
+
write(key, value, expires_in: expires_in, namespace: namespace)
|
221
|
+
end
|
222
|
+
|
223
|
+
# Inspects a key and returns all meta data for it
|
224
|
+
def inspect(key, namespace: nil) # rubocop:disable Metrics/MethodLength
|
225
|
+
key = namespaced_key(key, namespace)
|
226
|
+
idx = bucket_index(key)
|
227
|
+
store = @stores[idx]
|
228
|
+
mutex = @mutexes[idx]
|
229
|
+
|
230
|
+
mutex.synchronize do
|
231
|
+
entry = store[key]
|
232
|
+
return nil unless entry
|
233
|
+
|
234
|
+
{
|
235
|
+
key: key,
|
236
|
+
bucket: idx,
|
237
|
+
expires_at: entry[:expires_at],
|
238
|
+
created_at: entry[:created_at],
|
239
|
+
size_bytes: key.bytesize + entry[:value].bytesize,
|
240
|
+
compressed: compress
|
241
|
+
}
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
176
245
|
# Removes expired keys across all buckets
|
177
246
|
def cleanup_expired!
|
178
247
|
now = Time.now
|
@@ -180,10 +249,8 @@ class Mudis
|
|
180
249
|
mutex = @mutexes[idx]
|
181
250
|
store = @stores[idx]
|
182
251
|
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
|
252
|
+
store.keys.each do |key| # rubocop:disable Style/HashEachMethods
|
253
|
+
evict_key(idx, key) if store[key][:expires_at] && now > store[key][:expires_at]
|
187
254
|
end
|
188
255
|
end
|
189
256
|
end
|
@@ -210,6 +277,15 @@ class Mudis
|
|
210
277
|
@max_bytes
|
211
278
|
end
|
212
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
|
+
|
213
289
|
private
|
214
290
|
|
215
291
|
# Decompresses and deserializes a raw value
|
@@ -268,6 +344,11 @@ class Mudis
|
|
268
344
|
@lru_tails[idx] = node.prev
|
269
345
|
end
|
270
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
|
271
353
|
end
|
272
354
|
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
|
@@ -11,7 +12,7 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
11
12
|
Mudis.instance_variable_set(:@lru_tails, Array.new(Mudis.buckets) { nil })
|
12
13
|
Mudis.instance_variable_set(:@lru_nodes, Array.new(Mudis.buckets) { {} })
|
13
14
|
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.instance_variable_set(:@metrics, { hits: 0, misses: 0, evictions: 0, rejected: 0 })
|
15
16
|
Mudis.serializer = JSON
|
16
17
|
Mudis.compress = false
|
17
18
|
Mudis.max_value_bytes = nil
|
@@ -56,6 +57,81 @@ 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
|
+
|
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
|
+
|
59
135
|
describe "expiry handling" do
|
60
136
|
it "expires values after specified time" do
|
61
137
|
Mudis.write("short_lived", "gone soon", expires_in: 1)
|
@@ -75,6 +151,35 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
75
151
|
end
|
76
152
|
end
|
77
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
|
+
|
78
183
|
describe "LRU eviction" do
|
79
184
|
it "evicts old entries when size limit is reached" do
|
80
185
|
Mudis.stop_expiry_thread
|
@@ -87,7 +192,7 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
|
|
87
192
|
Mudis.instance_variable_set(:@lru_tails, [nil])
|
88
193
|
Mudis.instance_variable_set(:@lru_nodes, [{}])
|
89
194
|
Mudis.instance_variable_set(:@current_bytes, [0])
|
90
|
-
|
195
|
+
Mudis.hard_memory_limit = false
|
91
196
|
# Set very small threshold
|
92
197
|
Mudis.instance_variable_set(:@threshold_bytes, 60)
|
93
198
|
Mudis.max_value_bytes = 100
|
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.3.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
|