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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05a589eca123a045badb19d25119bbc9e51c48e6afec22c490615f656c73f65f
4
- data.tar.gz: 478553d02b5c62f08d9194355136b0e284c257d47184ba74d79c2d9a26553460
3
+ metadata.gz: cdff64ca287bd83eaa37c48afbc375e4303b7d3cc8d368b80c1d996d9ad0e3c0
4
+ data.tar.gz: 20d5d5fbc3afdd79d5a6d826a4a62419d1af60532e4b2515d4d9b2c0956e6174
5
5
  SHA512:
6
- metadata.gz: bbae5238c153bfc4d29e0efd795c01e6c473fd17a69b28e145dc4fe8ada0faa286fce117cb53a0eaba0df8c67560c08814eb15305fe5bf01e405751b56a6f218
7
- data.tar.gz: 1807351fac67cc35c66dc898264807b9542871c2e708baaa2e3fc91bd28b9cda1913257a9083e3115da341b836bd7ce3ca15ef23aae8cf998da4fbc65e6f0e6d
6
+ metadata.gz: 7de9f04b093e0ae1fadbd7f967fdf843aff8b7d530e766454848ac43afae8bce58228f4d8d3a05349d4d4983c7163b9063a31181de9eb0d30ac0fc3cac7da6b3
7
+ data.tar.gz: '097252131a51b2dc34db4b8da6dc442ccaa01eb8c49334cfdd658bef9321330f7d8c75037e61abce08ffd6491673d92c75f7438419a964b022c6b53749159936'
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ![mudis_signet](design/mudis.png "Mudis")
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/mudis.svg)](https://rubygems.org/gems/mudis)
3
+ [![Gem Version](https://badge.fury.io/rb/mudis.svg?icon=si%3Arubygems)](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 (TODO: add more useful abstraction once DSL and namespacing is introduced):
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
- 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)
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:#{current_user.id}")
134
- cache.write({ preferences: "dark" }, expires_in: 3600)
135
- 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", ... }
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
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- MUDIS_VERSION = "0.2.0"
3
+ MUDIS_VERSION = "0.3.0"
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 behavior
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mudis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kiebor81