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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1586fbcdac3ae237f88954090971beca8be02f3892f36c25363403d2ac80f34f
4
- data.tar.gz: b12179ea11d6f51a3049d3eaa4428648fc088ecb6ade607bbe832227ceb64678
3
+ metadata.gz: cdff64ca287bd83eaa37c48afbc375e4303b7d3cc8d368b80c1d996d9ad0e3c0
4
+ data.tar.gz: 20d5d5fbc3afdd79d5a6d826a4a62419d1af60532e4b2515d4d9b2c0956e6174
5
5
  SHA512:
6
- metadata.gz: bff8cda9a3f627f6c94b3f40b737365603ab51fd6edd8e845f058bed517ccfa86a4ad61465fb4b807540525b779586cfbbe77eccd8a16ab59afc69aa2f7cd43a
7
- data.tar.gz: 544c25fee1a7346e8c0725ee5e83d5764f3028d76a261666ecdc0d3291c2bccf5a52cf07713d480750cb149e12c52201a2da2a6de0faa8f191ea45b7ba81d47b
6
+ metadata.gz: 7de9f04b093e0ae1fadbd7f967fdf843aff8b7d530e766454848ac43afae8bce58228f4d8d3a05349d4d4983c7163b9063a31181de9eb0d30ac0fc3cac7da6b3
7
+ data.tar.gz: '097252131a51b2dc34db4b8da6dc442ccaa01eb8c49334cfdd658bef9321330f7d8c75037e61abce08ffd6491673d92c75f7438419a964b022c6b53749159936'
data/README.md CHANGED
@@ -1,9 +1,13 @@
1
1
  ![mudis_signet](design/mudis.png "Mudis")
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/mudis.svg?icon=si%3Arubygems)](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
- def initialize(cache_key)
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 = MudisCacheService.new("user:#{current_user.id}")
130
- cache.write({ preferences: "dark" }, expires_in: 3600)
131
- cache.read # => { "preferences" => "dark" }
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 **process-local** and **non-persistent**.
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
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- MUDIS_VERSION = "0.1.0"
3
+ MUDIS_VERSION = "0.3.0"
data/lib/mudis.rb CHANGED
@@ -1,22 +1,23 @@
1
1
  # lib/mudis.rb
2
- require 'json'
3
- require 'thread'
4
- require 'zlib'
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 behavior
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['MUDIS_BUCKETS']&.to_i || 32)
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 # Eviction threshold at 90%
52
- @expiry_thread = nil # Background thread for expiry cleanup
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
- # spec/mudis_spec.rb
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.1.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-15 00:00:00.000000000 Z
11
+ date: 2025-07-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec