mudis 0.5.0 → 0.7.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 +695 -568
- data/lib/example_mudis_server_config.md +39 -0
- data/lib/mudis/version.rb +3 -3
- data/lib/mudis.rb +521 -497
- data/lib/mudis_client.rb +68 -0
- data/lib/mudis_config.rb +25 -25
- data/lib/mudis_server.rb +79 -0
- data/sig/mudis.rbs +56 -54
- data/sig/mudis_config.rbs +10 -10
- data/spec/eviction_spec.rb +29 -0
- data/spec/guardrails_spec.rb +138 -138
- data/spec/memory_guard_spec.rb +33 -0
- data/spec/metrics_spec.rb +34 -0
- data/spec/mudis_spec.rb +183 -314
- data/spec/namespace_spec.rb +69 -0
- data/spec/reset_spec.rb +31 -0
- metadata +16 -7
data/lib/mudis.rb
CHANGED
@@ -1,497 +1,521 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "json"
|
4
|
-
require "thread" # rubocop:disable Lint/RedundantRequireStatement
|
5
|
-
require "zlib"
|
6
|
-
|
7
|
-
require_relative "mudis_config"
|
8
|
-
|
9
|
-
# Mudis is a thread-safe, in-memory, sharded, LRU cache with optional compression and expiry.
|
10
|
-
# It is designed for high concurrency and performance within a Ruby application.
|
11
|
-
class Mudis # rubocop:disable Metrics/ClassLength
|
12
|
-
# --- Global Configuration and State ---
|
13
|
-
|
14
|
-
@serializer = JSON # Default serializer (can be changed to Marshal or Oj)
|
15
|
-
@compress = false # Whether to compress values with Zlib
|
16
|
-
@metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 } # Metrics tracking read/write behaviour
|
17
|
-
@metrics_mutex = Mutex.new # Mutex for synchronizing access to metrics
|
18
|
-
@max_value_bytes = nil # Optional size cap per value
|
19
|
-
@stop_expiry = false # Signal for stopping expiry thread
|
20
|
-
@max_ttl = nil # Optional maximum TTL for cache entries
|
21
|
-
@default_ttl = nil # Default TTL for cache entries if not specified
|
22
|
-
|
23
|
-
class << self
|
24
|
-
attr_accessor :serializer, :compress, :hard_memory_limit, :max_ttl, :default_ttl
|
25
|
-
attr_reader :max_bytes, :max_value_bytes
|
26
|
-
|
27
|
-
# Configures Mudis with a block, allowing customization of settings
|
28
|
-
def configure
|
29
|
-
yield(config)
|
30
|
-
apply_config!
|
31
|
-
end
|
32
|
-
|
33
|
-
# Returns the current configuration object
|
34
|
-
def config
|
35
|
-
@config ||= MudisConfig.new
|
36
|
-
end
|
37
|
-
|
38
|
-
# Applies the current configuration to Mudis
|
39
|
-
def apply_config! # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
40
|
-
validate_config!
|
41
|
-
|
42
|
-
self.serializer = config.serializer
|
43
|
-
self.compress = config.compress
|
44
|
-
self.max_value_bytes = config.max_value_bytes
|
45
|
-
self.hard_memory_limit = config.hard_memory_limit
|
46
|
-
self.max_bytes = config.max_bytes
|
47
|
-
self.max_ttl = config.max_ttl
|
48
|
-
self.default_ttl = config.default_ttl
|
49
|
-
|
50
|
-
if config.buckets # rubocop:disable Style/GuardClause
|
51
|
-
@buckets = config.buckets
|
52
|
-
reset!
|
53
|
-
end
|
54
|
-
end
|
55
|
-
|
56
|
-
# Validates the current configuration, raising errors for invalid settings
|
57
|
-
def validate_config! # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
58
|
-
if config.max_value_bytes && config.max_value_bytes > config.max_bytes
|
59
|
-
raise ArgumentError,
|
60
|
-
"max_value_bytes cannot exceed max_bytes"
|
61
|
-
end
|
62
|
-
|
63
|
-
raise ArgumentError, "max_value_bytes must be > 0" if config.max_value_bytes && config.max_value_bytes <= 0
|
64
|
-
|
65
|
-
raise ArgumentError, "buckets must be > 0" if config.buckets && config.buckets <= 0
|
66
|
-
raise ArgumentError, "max_ttl must be > 0" if config.max_ttl && config.max_ttl <= 0
|
67
|
-
raise ArgumentError, "default_ttl must be > 0" if config.default_ttl && config.default_ttl <= 0
|
68
|
-
end
|
69
|
-
|
70
|
-
# Returns a snapshot of metrics (thread-safe)
|
71
|
-
def metrics # rubocop:disable Metrics/MethodLength
|
72
|
-
@metrics_mutex.synchronize do
|
73
|
-
{
|
74
|
-
hits: @metrics[:hits],
|
75
|
-
misses: @metrics[:misses],
|
76
|
-
evictions: @metrics[:evictions],
|
77
|
-
rejected: @metrics[:rejected],
|
78
|
-
total_memory: current_memory_bytes,
|
79
|
-
least_touched: least_touched(10),
|
80
|
-
buckets: buckets.times.map do |idx|
|
81
|
-
{
|
82
|
-
index: idx,
|
83
|
-
keys: @stores[idx].size,
|
84
|
-
memory_bytes: @current_bytes[idx],
|
85
|
-
lru_size: @lru_nodes[idx].size
|
86
|
-
}
|
87
|
-
end
|
88
|
-
}
|
89
|
-
end
|
90
|
-
end
|
91
|
-
|
92
|
-
# Resets metric counters (thread-safe)
|
93
|
-
def reset_metrics!
|
94
|
-
@metrics_mutex.synchronize do
|
95
|
-
@metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 }
|
96
|
-
end
|
97
|
-
end
|
98
|
-
|
99
|
-
# Fully resets all internal state (except config)
|
100
|
-
def reset!
|
101
|
-
stop_expiry_thread
|
102
|
-
|
103
|
-
@buckets = nil
|
104
|
-
b = buckets
|
105
|
-
|
106
|
-
@stores = Array.new(b) { {} }
|
107
|
-
@mutexes = Array.new(b) { Mutex.new }
|
108
|
-
@lru_heads = Array.new(b) { nil }
|
109
|
-
@lru_tails = Array.new(b) { nil }
|
110
|
-
@lru_nodes = Array.new(b) { {} }
|
111
|
-
@current_bytes = Array.new(b, 0)
|
112
|
-
|
113
|
-
reset_metrics!
|
114
|
-
end
|
115
|
-
|
116
|
-
# Sets the maximum size for a single value in bytes
|
117
|
-
def max_bytes=(value)
|
118
|
-
raise ArgumentError, "max_bytes must be > 0" if value.to_i <= 0
|
119
|
-
|
120
|
-
@max_bytes = value
|
121
|
-
@threshold_bytes = (@max_bytes * 0.9).to_i
|
122
|
-
end
|
123
|
-
|
124
|
-
# Sets the maximum size for a single value in bytes, raising an error if invalid
|
125
|
-
def max_value_bytes=(value)
|
126
|
-
raise ArgumentError, "max_value_bytes must be > 0" if value && value.to_i <= 0
|
127
|
-
|
128
|
-
@max_value_bytes = value
|
129
|
-
end
|
130
|
-
end
|
131
|
-
|
132
|
-
# Node structure for the LRU doubly-linked list
|
133
|
-
class LRUNode
|
134
|
-
attr_accessor :key, :prev, :next
|
135
|
-
|
136
|
-
def initialize(key)
|
137
|
-
@key = key
|
138
|
-
@prev = nil
|
139
|
-
@next = nil
|
140
|
-
end
|
141
|
-
end
|
142
|
-
|
143
|
-
# Number of cache buckets (shards). Default: 32
|
144
|
-
def self.buckets
|
145
|
-
return @buckets if @buckets
|
146
|
-
|
147
|
-
val = config.buckets || ENV["MUDIS_BUCKETS"]&.to_i || 32
|
148
|
-
raise ArgumentError, "bucket count must be > 0" if val <= 0
|
149
|
-
|
150
|
-
@buckets = val
|
151
|
-
end
|
152
|
-
|
153
|
-
# --- Internal Structures ---
|
154
|
-
|
155
|
-
@stores = Array.new(buckets) { {} } # Array of hash buckets for storage
|
156
|
-
@mutexes = Array.new(buckets) { Mutex.new } # Per-bucket mutexes
|
157
|
-
@lru_heads = Array.new(buckets) { nil } # Head node for each LRU list
|
158
|
-
@lru_tails = Array.new(buckets) { nil } # Tail node for each LRU list
|
159
|
-
@lru_nodes = Array.new(buckets) { {} } # Map of key => LRU node
|
160
|
-
@current_bytes = Array.new(buckets, 0) # Memory usage per bucket
|
161
|
-
@max_bytes = 1_073_741_824 # 1 GB global max cache size
|
162
|
-
@threshold_bytes = (@max_bytes * 0.9).to_i # Eviction threshold at 90%
|
163
|
-
@expiry_thread = nil # Background thread for expiry cleanup
|
164
|
-
@hard_memory_limit = false # Whether to enforce hard memory cap
|
165
|
-
|
166
|
-
class << self
|
167
|
-
# Starts a thread that periodically removes expired entries
|
168
|
-
def start_expiry_thread(interval: 60)
|
169
|
-
return if @expiry_thread&.alive?
|
170
|
-
|
171
|
-
@stop_expiry = false
|
172
|
-
@expiry_thread = Thread.new do
|
173
|
-
loop do
|
174
|
-
break if @stop_expiry
|
175
|
-
|
176
|
-
sleep interval
|
177
|
-
cleanup_expired!
|
178
|
-
end
|
179
|
-
end
|
180
|
-
end
|
181
|
-
|
182
|
-
# Signals and joins the expiry thread
|
183
|
-
def stop_expiry_thread
|
184
|
-
@stop_expiry = true
|
185
|
-
@expiry_thread&.join
|
186
|
-
@expiry_thread = nil
|
187
|
-
end
|
188
|
-
|
189
|
-
# Computes which bucket a key belongs to
|
190
|
-
def bucket_index(key)
|
191
|
-
key.hash % buckets
|
192
|
-
end
|
193
|
-
|
194
|
-
# Checks if a key exists and is not expired
|
195
|
-
def exists?(key, namespace: nil)
|
196
|
-
key = namespaced_key(key, namespace)
|
197
|
-
!!read(key)
|
198
|
-
end
|
199
|
-
|
200
|
-
# Reads and returns the value for a key, updating LRU and metrics
|
201
|
-
def read(key, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
202
|
-
key = namespaced_key(key, namespace)
|
203
|
-
raw_entry = nil
|
204
|
-
idx = bucket_index(key)
|
205
|
-
mutex = @mutexes[idx]
|
206
|
-
store = @stores[idx]
|
207
|
-
|
208
|
-
mutex.synchronize do
|
209
|
-
raw_entry = @stores[idx][key]
|
210
|
-
if raw_entry && raw_entry[:expires_at] && Time.now > raw_entry[:expires_at]
|
211
|
-
evict_key(idx, key)
|
212
|
-
raw_entry = nil
|
213
|
-
end
|
214
|
-
|
215
|
-
store[key][:touches] = (store[key][:touches] || 0) + 1 if store[key]
|
216
|
-
|
217
|
-
metric(:hits) if raw_entry
|
218
|
-
metric(:misses) unless raw_entry
|
219
|
-
end
|
220
|
-
|
221
|
-
return nil unless raw_entry
|
222
|
-
|
223
|
-
value = decompress_and_deserialize(raw_entry[:value])
|
224
|
-
promote_lru(idx, key)
|
225
|
-
value
|
226
|
-
end
|
227
|
-
|
228
|
-
# Writes a value to the cache with optional expiry and LRU tracking
|
229
|
-
def write(key, value, expires_in: nil, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
|
230
|
-
key = namespaced_key(key, namespace)
|
231
|
-
raw = serializer.dump(value)
|
232
|
-
raw = Zlib::Deflate.deflate(raw) if compress
|
233
|
-
size = key.bytesize + raw.bytesize
|
234
|
-
return if max_value_bytes && raw.bytesize > max_value_bytes
|
235
|
-
|
236
|
-
if hard_memory_limit && current_memory_bytes + size > max_memory_bytes
|
237
|
-
metric(:rejected)
|
238
|
-
return
|
239
|
-
end
|
240
|
-
|
241
|
-
# Ensure expires_in respects max_ttl and default_ttl
|
242
|
-
expires_in = effective_ttl(expires_in)
|
243
|
-
|
244
|
-
idx = bucket_index(key)
|
245
|
-
mutex = @mutexes[idx]
|
246
|
-
store = @stores[idx]
|
247
|
-
|
248
|
-
mutex.synchronize do
|
249
|
-
evict_key(idx, key) if store[key]
|
250
|
-
|
251
|
-
while @current_bytes[idx] + size > (@threshold_bytes / buckets) && @lru_tails[idx]
|
252
|
-
evict_key(idx, @lru_tails[idx].key)
|
253
|
-
metric(:evictions)
|
254
|
-
end
|
255
|
-
|
256
|
-
store[key] = {
|
257
|
-
value: raw,
|
258
|
-
expires_at: expires_in ? Time.now + expires_in : nil,
|
259
|
-
created_at: Time.now,
|
260
|
-
touches: 0
|
261
|
-
}
|
262
|
-
|
263
|
-
insert_lru(idx, key)
|
264
|
-
@current_bytes[idx] += size
|
265
|
-
end
|
266
|
-
end
|
267
|
-
|
268
|
-
# Atomically updates the value for a key using a block
|
269
|
-
def update(key, namespace: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
270
|
-
key = namespaced_key(key, namespace)
|
271
|
-
idx = bucket_index(key)
|
272
|
-
mutex = @mutexes[idx]
|
273
|
-
store = @stores[idx]
|
274
|
-
|
275
|
-
raw_entry = nil
|
276
|
-
mutex.synchronize do
|
277
|
-
raw_entry = store[key]
|
278
|
-
return nil unless raw_entry
|
279
|
-
end
|
280
|
-
|
281
|
-
value = decompress_and_deserialize(raw_entry[:value])
|
282
|
-
new_value = yield(value)
|
283
|
-
new_raw = serializer.dump(new_value)
|
284
|
-
new_raw = Zlib::Deflate.deflate(new_raw) if compress
|
285
|
-
|
286
|
-
mutex.synchronize do
|
287
|
-
old_size = key.bytesize + raw_entry[:value].bytesize
|
288
|
-
new_size = key.bytesize + new_raw.bytesize
|
289
|
-
store[key][:value] = new_raw
|
290
|
-
@current_bytes[idx] += (new_size - old_size)
|
291
|
-
promote_lru(idx, key)
|
292
|
-
end
|
293
|
-
end
|
294
|
-
|
295
|
-
# Deletes a key from the cache
|
296
|
-
def delete(key, namespace: nil)
|
297
|
-
key = namespaced_key(key, namespace)
|
298
|
-
idx = bucket_index(key)
|
299
|
-
mutex = @mutexes[idx]
|
300
|
-
|
301
|
-
mutex.synchronize do
|
302
|
-
evict_key(idx, key)
|
303
|
-
end
|
304
|
-
end
|
305
|
-
|
306
|
-
# Fetches a value for a key, writing it if not present or expired
|
307
|
-
# The block is executed to generate the value if it doesn't exist
|
308
|
-
# Optionally accepts an expiration time
|
309
|
-
# If force is true, it always fetches and writes the value
|
310
|
-
def fetch(key, expires_in: nil, force: false, namespace: nil)
|
311
|
-
key = namespaced_key(key, namespace)
|
312
|
-
unless force
|
313
|
-
cached = read(key)
|
314
|
-
return cached if cached
|
315
|
-
end
|
316
|
-
|
317
|
-
value = yield
|
318
|
-
write(key, value, expires_in: expires_in)
|
319
|
-
value
|
320
|
-
end
|
321
|
-
|
322
|
-
# Clears a specific key from the cache, a semantic synonym for delete
|
323
|
-
# This method is provided for clarity in usage
|
324
|
-
# It behaves the same as delete
|
325
|
-
def clear(key, namespace: nil)
|
326
|
-
delete(key, namespace: namespace)
|
327
|
-
end
|
328
|
-
|
329
|
-
# Replaces the value for a key if it exists, otherwise does nothing
|
330
|
-
# This is useful for updating values without needing to check existence first
|
331
|
-
# It will write the new value and update the expiration if provided
|
332
|
-
# If the key does not exist, it will not create a new entry
|
333
|
-
def replace(key, value, expires_in: nil, namespace: nil)
|
334
|
-
return unless exists?(key, namespace: namespace)
|
335
|
-
|
336
|
-
write(key, value, expires_in: expires_in, namespace: namespace)
|
337
|
-
end
|
338
|
-
|
339
|
-
# Inspects a key and returns all meta data for it
|
340
|
-
def inspect(key, namespace: nil) # rubocop:disable Metrics/MethodLength
|
341
|
-
key = namespaced_key(key, namespace)
|
342
|
-
idx = bucket_index(key)
|
343
|
-
store = @stores[idx]
|
344
|
-
mutex = @mutexes[idx]
|
345
|
-
|
346
|
-
mutex.synchronize do
|
347
|
-
entry = store[key]
|
348
|
-
return nil unless entry
|
349
|
-
|
350
|
-
{
|
351
|
-
key: key,
|
352
|
-
bucket: idx,
|
353
|
-
expires_at: entry[:expires_at],
|
354
|
-
created_at: entry[:created_at],
|
355
|
-
size_bytes: key.bytesize + entry[:value].bytesize,
|
356
|
-
compressed: compress
|
357
|
-
}
|
358
|
-
end
|
359
|
-
end
|
360
|
-
|
361
|
-
# Removes expired keys across all buckets
|
362
|
-
def cleanup_expired!
|
363
|
-
now = Time.now
|
364
|
-
buckets.times do |idx|
|
365
|
-
mutex = @mutexes[idx]
|
366
|
-
store = @stores[idx]
|
367
|
-
mutex.synchronize do
|
368
|
-
store.keys.each do |key| # rubocop:disable Style/HashEachMethods
|
369
|
-
evict_key(idx, key) if store[key][:expires_at] && now > store[key][:expires_at]
|
370
|
-
end
|
371
|
-
end
|
372
|
-
end
|
373
|
-
end
|
374
|
-
|
375
|
-
# Returns an array of all cache keys
|
376
|
-
def all_keys
|
377
|
-
keys = []
|
378
|
-
buckets.times do |idx|
|
379
|
-
mutex = @mutexes[idx]
|
380
|
-
store = @stores[idx]
|
381
|
-
mutex.synchronize { keys.concat(store.keys) }
|
382
|
-
end
|
383
|
-
keys
|
384
|
-
end
|
385
|
-
|
386
|
-
# Returns
|
387
|
-
def
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
if node
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
end
|
481
|
-
|
482
|
-
#
|
483
|
-
def
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
end
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "thread" # rubocop:disable Lint/RedundantRequireStatement
|
5
|
+
require "zlib"
|
6
|
+
|
7
|
+
require_relative "mudis_config"
|
8
|
+
|
9
|
+
# Mudis is a thread-safe, in-memory, sharded, LRU cache with optional compression and expiry.
|
10
|
+
# It is designed for high concurrency and performance within a Ruby application.
|
11
|
+
class Mudis # rubocop:disable Metrics/ClassLength
|
12
|
+
# --- Global Configuration and State ---
|
13
|
+
|
14
|
+
@serializer = JSON # Default serializer (can be changed to Marshal or Oj)
|
15
|
+
@compress = false # Whether to compress values with Zlib
|
16
|
+
@metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 } # Metrics tracking read/write behaviour
|
17
|
+
@metrics_mutex = Mutex.new # Mutex for synchronizing access to metrics
|
18
|
+
@max_value_bytes = nil # Optional size cap per value
|
19
|
+
@stop_expiry = false # Signal for stopping expiry thread
|
20
|
+
@max_ttl = nil # Optional maximum TTL for cache entries
|
21
|
+
@default_ttl = nil # Default TTL for cache entries if not specified
|
22
|
+
|
23
|
+
class << self
|
24
|
+
attr_accessor :serializer, :compress, :hard_memory_limit, :max_ttl, :default_ttl
|
25
|
+
attr_reader :max_bytes, :max_value_bytes
|
26
|
+
|
27
|
+
# Configures Mudis with a block, allowing customization of settings
|
28
|
+
def configure
|
29
|
+
yield(config)
|
30
|
+
apply_config!
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns the current configuration object
|
34
|
+
def config
|
35
|
+
@config ||= MudisConfig.new
|
36
|
+
end
|
37
|
+
|
38
|
+
# Applies the current configuration to Mudis
|
39
|
+
def apply_config! # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
40
|
+
validate_config!
|
41
|
+
|
42
|
+
self.serializer = config.serializer
|
43
|
+
self.compress = config.compress
|
44
|
+
self.max_value_bytes = config.max_value_bytes
|
45
|
+
self.hard_memory_limit = config.hard_memory_limit
|
46
|
+
self.max_bytes = config.max_bytes
|
47
|
+
self.max_ttl = config.max_ttl
|
48
|
+
self.default_ttl = config.default_ttl
|
49
|
+
|
50
|
+
if config.buckets # rubocop:disable Style/GuardClause
|
51
|
+
@buckets = config.buckets
|
52
|
+
reset!
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Validates the current configuration, raising errors for invalid settings
|
57
|
+
def validate_config! # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
58
|
+
if config.max_value_bytes && config.max_value_bytes > config.max_bytes
|
59
|
+
raise ArgumentError,
|
60
|
+
"max_value_bytes cannot exceed max_bytes"
|
61
|
+
end
|
62
|
+
|
63
|
+
raise ArgumentError, "max_value_bytes must be > 0" if config.max_value_bytes && config.max_value_bytes <= 0
|
64
|
+
|
65
|
+
raise ArgumentError, "buckets must be > 0" if config.buckets && config.buckets <= 0
|
66
|
+
raise ArgumentError, "max_ttl must be > 0" if config.max_ttl && config.max_ttl <= 0
|
67
|
+
raise ArgumentError, "default_ttl must be > 0" if config.default_ttl && config.default_ttl <= 0
|
68
|
+
end
|
69
|
+
|
70
|
+
# Returns a snapshot of metrics (thread-safe)
|
71
|
+
def metrics # rubocop:disable Metrics/MethodLength
|
72
|
+
@metrics_mutex.synchronize do
|
73
|
+
{
|
74
|
+
hits: @metrics[:hits],
|
75
|
+
misses: @metrics[:misses],
|
76
|
+
evictions: @metrics[:evictions],
|
77
|
+
rejected: @metrics[:rejected],
|
78
|
+
total_memory: current_memory_bytes,
|
79
|
+
least_touched: least_touched(10),
|
80
|
+
buckets: buckets.times.map do |idx|
|
81
|
+
{
|
82
|
+
index: idx,
|
83
|
+
keys: @stores[idx].size,
|
84
|
+
memory_bytes: @current_bytes[idx],
|
85
|
+
lru_size: @lru_nodes[idx].size
|
86
|
+
}
|
87
|
+
end
|
88
|
+
}
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Resets metric counters (thread-safe)
|
93
|
+
def reset_metrics!
|
94
|
+
@metrics_mutex.synchronize do
|
95
|
+
@metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 }
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Fully resets all internal state (except config)
|
100
|
+
def reset!
|
101
|
+
stop_expiry_thread
|
102
|
+
|
103
|
+
@buckets = nil
|
104
|
+
b = buckets
|
105
|
+
|
106
|
+
@stores = Array.new(b) { {} }
|
107
|
+
@mutexes = Array.new(b) { Mutex.new }
|
108
|
+
@lru_heads = Array.new(b) { nil }
|
109
|
+
@lru_tails = Array.new(b) { nil }
|
110
|
+
@lru_nodes = Array.new(b) { {} }
|
111
|
+
@current_bytes = Array.new(b, 0)
|
112
|
+
|
113
|
+
reset_metrics!
|
114
|
+
end
|
115
|
+
|
116
|
+
# Sets the maximum size for a single value in bytes
|
117
|
+
def max_bytes=(value)
|
118
|
+
raise ArgumentError, "max_bytes must be > 0" if value.to_i <= 0
|
119
|
+
|
120
|
+
@max_bytes = value
|
121
|
+
@threshold_bytes = (@max_bytes * 0.9).to_i
|
122
|
+
end
|
123
|
+
|
124
|
+
# Sets the maximum size for a single value in bytes, raising an error if invalid
|
125
|
+
def max_value_bytes=(value)
|
126
|
+
raise ArgumentError, "max_value_bytes must be > 0" if value && value.to_i <= 0
|
127
|
+
|
128
|
+
@max_value_bytes = value
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Node structure for the LRU doubly-linked list
|
133
|
+
class LRUNode
|
134
|
+
attr_accessor :key, :prev, :next
|
135
|
+
|
136
|
+
def initialize(key)
|
137
|
+
@key = key
|
138
|
+
@prev = nil
|
139
|
+
@next = nil
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Number of cache buckets (shards). Default: 32
|
144
|
+
def self.buckets
|
145
|
+
return @buckets if @buckets
|
146
|
+
|
147
|
+
val = config.buckets || ENV["MUDIS_BUCKETS"]&.to_i || 32
|
148
|
+
raise ArgumentError, "bucket count must be > 0" if val <= 0
|
149
|
+
|
150
|
+
@buckets = val
|
151
|
+
end
|
152
|
+
|
153
|
+
# --- Internal Structures ---
|
154
|
+
|
155
|
+
@stores = Array.new(buckets) { {} } # Array of hash buckets for storage
|
156
|
+
@mutexes = Array.new(buckets) { Mutex.new } # Per-bucket mutexes
|
157
|
+
@lru_heads = Array.new(buckets) { nil } # Head node for each LRU list
|
158
|
+
@lru_tails = Array.new(buckets) { nil } # Tail node for each LRU list
|
159
|
+
@lru_nodes = Array.new(buckets) { {} } # Map of key => LRU node
|
160
|
+
@current_bytes = Array.new(buckets, 0) # Memory usage per bucket
|
161
|
+
@max_bytes = 1_073_741_824 # 1 GB global max cache size
|
162
|
+
@threshold_bytes = (@max_bytes * 0.9).to_i # Eviction threshold at 90%
|
163
|
+
@expiry_thread = nil # Background thread for expiry cleanup
|
164
|
+
@hard_memory_limit = false # Whether to enforce hard memory cap
|
165
|
+
|
166
|
+
class << self
|
167
|
+
# Starts a thread that periodically removes expired entries
|
168
|
+
def start_expiry_thread(interval: 60)
|
169
|
+
return if @expiry_thread&.alive?
|
170
|
+
|
171
|
+
@stop_expiry = false
|
172
|
+
@expiry_thread = Thread.new do
|
173
|
+
loop do
|
174
|
+
break if @stop_expiry
|
175
|
+
|
176
|
+
sleep interval
|
177
|
+
cleanup_expired!
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# Signals and joins the expiry thread
|
183
|
+
def stop_expiry_thread
|
184
|
+
@stop_expiry = true
|
185
|
+
@expiry_thread&.join
|
186
|
+
@expiry_thread = nil
|
187
|
+
end
|
188
|
+
|
189
|
+
# Computes which bucket a key belongs to
|
190
|
+
def bucket_index(key)
|
191
|
+
key.hash % buckets
|
192
|
+
end
|
193
|
+
|
194
|
+
# Checks if a key exists and is not expired
|
195
|
+
def exists?(key, namespace: nil)
|
196
|
+
key = namespaced_key(key, namespace)
|
197
|
+
!!read(key)
|
198
|
+
end
|
199
|
+
|
200
|
+
# Reads and returns the value for a key, updating LRU and metrics
|
201
|
+
def read(key, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
202
|
+
key = namespaced_key(key, namespace)
|
203
|
+
raw_entry = nil
|
204
|
+
idx = bucket_index(key)
|
205
|
+
mutex = @mutexes[idx]
|
206
|
+
store = @stores[idx]
|
207
|
+
|
208
|
+
mutex.synchronize do
|
209
|
+
raw_entry = @stores[idx][key]
|
210
|
+
if raw_entry && raw_entry[:expires_at] && Time.now > raw_entry[:expires_at]
|
211
|
+
evict_key(idx, key)
|
212
|
+
raw_entry = nil
|
213
|
+
end
|
214
|
+
|
215
|
+
store[key][:touches] = (store[key][:touches] || 0) + 1 if store[key]
|
216
|
+
|
217
|
+
metric(:hits) if raw_entry
|
218
|
+
metric(:misses) unless raw_entry
|
219
|
+
end
|
220
|
+
|
221
|
+
return nil unless raw_entry
|
222
|
+
|
223
|
+
value = decompress_and_deserialize(raw_entry[:value])
|
224
|
+
promote_lru(idx, key)
|
225
|
+
value
|
226
|
+
end
|
227
|
+
|
228
|
+
# Writes a value to the cache with optional expiry and LRU tracking
|
229
|
+
def write(key, value, expires_in: nil, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
|
230
|
+
key = namespaced_key(key, namespace)
|
231
|
+
raw = serializer.dump(value)
|
232
|
+
raw = Zlib::Deflate.deflate(raw) if compress
|
233
|
+
size = key.bytesize + raw.bytesize
|
234
|
+
return if max_value_bytes && raw.bytesize > max_value_bytes
|
235
|
+
|
236
|
+
if hard_memory_limit && current_memory_bytes + size > max_memory_bytes
|
237
|
+
metric(:rejected)
|
238
|
+
return
|
239
|
+
end
|
240
|
+
|
241
|
+
# Ensure expires_in respects max_ttl and default_ttl
|
242
|
+
expires_in = effective_ttl(expires_in)
|
243
|
+
|
244
|
+
idx = bucket_index(key)
|
245
|
+
mutex = @mutexes[idx]
|
246
|
+
store = @stores[idx]
|
247
|
+
|
248
|
+
mutex.synchronize do
|
249
|
+
evict_key(idx, key) if store[key]
|
250
|
+
|
251
|
+
while @current_bytes[idx] + size > (@threshold_bytes / buckets) && @lru_tails[idx]
|
252
|
+
evict_key(idx, @lru_tails[idx].key)
|
253
|
+
metric(:evictions)
|
254
|
+
end
|
255
|
+
|
256
|
+
store[key] = {
|
257
|
+
value: raw,
|
258
|
+
expires_at: expires_in ? Time.now + expires_in : nil,
|
259
|
+
created_at: Time.now,
|
260
|
+
touches: 0
|
261
|
+
}
|
262
|
+
|
263
|
+
insert_lru(idx, key)
|
264
|
+
@current_bytes[idx] += size
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
# Atomically updates the value for a key using a block
|
269
|
+
def update(key, namespace: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
270
|
+
key = namespaced_key(key, namespace)
|
271
|
+
idx = bucket_index(key)
|
272
|
+
mutex = @mutexes[idx]
|
273
|
+
store = @stores[idx]
|
274
|
+
|
275
|
+
raw_entry = nil
|
276
|
+
mutex.synchronize do
|
277
|
+
raw_entry = store[key]
|
278
|
+
return nil unless raw_entry
|
279
|
+
end
|
280
|
+
|
281
|
+
value = decompress_and_deserialize(raw_entry[:value])
|
282
|
+
new_value = yield(value)
|
283
|
+
new_raw = serializer.dump(new_value)
|
284
|
+
new_raw = Zlib::Deflate.deflate(new_raw) if compress
|
285
|
+
|
286
|
+
mutex.synchronize do
|
287
|
+
old_size = key.bytesize + raw_entry[:value].bytesize
|
288
|
+
new_size = key.bytesize + new_raw.bytesize
|
289
|
+
store[key][:value] = new_raw
|
290
|
+
@current_bytes[idx] += (new_size - old_size)
|
291
|
+
promote_lru(idx, key)
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
# Deletes a key from the cache
|
296
|
+
def delete(key, namespace: nil)
|
297
|
+
key = namespaced_key(key, namespace)
|
298
|
+
idx = bucket_index(key)
|
299
|
+
mutex = @mutexes[idx]
|
300
|
+
|
301
|
+
mutex.synchronize do
|
302
|
+
evict_key(idx, key)
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
# Fetches a value for a key, writing it if not present or expired
|
307
|
+
# The block is executed to generate the value if it doesn't exist
|
308
|
+
# Optionally accepts an expiration time
|
309
|
+
# If force is true, it always fetches and writes the value
|
310
|
+
def fetch(key, expires_in: nil, force: false, namespace: nil)
|
311
|
+
key = namespaced_key(key, namespace)
|
312
|
+
unless force
|
313
|
+
cached = read(key)
|
314
|
+
return cached if cached
|
315
|
+
end
|
316
|
+
|
317
|
+
value = yield
|
318
|
+
write(key, value, expires_in: expires_in)
|
319
|
+
value
|
320
|
+
end
|
321
|
+
|
322
|
+
# Clears a specific key from the cache, a semantic synonym for delete
|
323
|
+
# This method is provided for clarity in usage
|
324
|
+
# It behaves the same as delete
|
325
|
+
def clear(key, namespace: nil)
|
326
|
+
delete(key, namespace: namespace)
|
327
|
+
end
|
328
|
+
|
329
|
+
# Replaces the value for a key if it exists, otherwise does nothing
|
330
|
+
# This is useful for updating values without needing to check existence first
|
331
|
+
# It will write the new value and update the expiration if provided
|
332
|
+
# If the key does not exist, it will not create a new entry
|
333
|
+
def replace(key, value, expires_in: nil, namespace: nil)
|
334
|
+
return unless exists?(key, namespace: namespace)
|
335
|
+
|
336
|
+
write(key, value, expires_in: expires_in, namespace: namespace)
|
337
|
+
end
|
338
|
+
|
339
|
+
# Inspects a key and returns all meta data for it
|
340
|
+
def inspect(key, namespace: nil) # rubocop:disable Metrics/MethodLength
|
341
|
+
key = namespaced_key(key, namespace)
|
342
|
+
idx = bucket_index(key)
|
343
|
+
store = @stores[idx]
|
344
|
+
mutex = @mutexes[idx]
|
345
|
+
|
346
|
+
mutex.synchronize do
|
347
|
+
entry = store[key]
|
348
|
+
return nil unless entry
|
349
|
+
|
350
|
+
{
|
351
|
+
key: key,
|
352
|
+
bucket: idx,
|
353
|
+
expires_at: entry[:expires_at],
|
354
|
+
created_at: entry[:created_at],
|
355
|
+
size_bytes: key.bytesize + entry[:value].bytesize,
|
356
|
+
compressed: compress
|
357
|
+
}
|
358
|
+
end
|
359
|
+
end
|
360
|
+
|
361
|
+
# Removes expired keys across all buckets
|
362
|
+
def cleanup_expired!
|
363
|
+
now = Time.now
|
364
|
+
buckets.times do |idx|
|
365
|
+
mutex = @mutexes[idx]
|
366
|
+
store = @stores[idx]
|
367
|
+
mutex.synchronize do
|
368
|
+
store.keys.each do |key| # rubocop:disable Style/HashEachMethods
|
369
|
+
evict_key(idx, key) if store[key][:expires_at] && now > store[key][:expires_at]
|
370
|
+
end
|
371
|
+
end
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
375
|
+
# Returns an array of all cache keys
|
376
|
+
def all_keys
|
377
|
+
keys = []
|
378
|
+
buckets.times do |idx|
|
379
|
+
mutex = @mutexes[idx]
|
380
|
+
store = @stores[idx]
|
381
|
+
mutex.synchronize { keys.concat(store.keys) }
|
382
|
+
end
|
383
|
+
keys
|
384
|
+
end
|
385
|
+
|
386
|
+
# Returns all keys in a specific namespace
|
387
|
+
def keys(namespace:)
|
388
|
+
raise ArgumentError, "namespace is required" unless namespace
|
389
|
+
|
390
|
+
prefix = "#{namespace}:"
|
391
|
+
all_keys.select { |key| key.start_with?(prefix) }.map { |key| key.delete_prefix(prefix) }
|
392
|
+
end
|
393
|
+
|
394
|
+
# Clears all keys in a specific namespace
|
395
|
+
def clear_namespace(namespace:)
|
396
|
+
raise ArgumentError, "namespace is required" unless namespace
|
397
|
+
|
398
|
+
prefix = "#{namespace}:"
|
399
|
+
buckets.times do |idx|
|
400
|
+
mutex = @mutexes[idx]
|
401
|
+
store = @stores[idx]
|
402
|
+
|
403
|
+
mutex.synchronize do
|
404
|
+
keys_to_delete = store.keys.select { |key| key.start_with?(prefix) }
|
405
|
+
keys_to_delete.each { |key| evict_key(idx, key) }
|
406
|
+
end
|
407
|
+
end
|
408
|
+
end
|
409
|
+
|
410
|
+
# Returns the least-touched keys across all buckets
|
411
|
+
def least_touched(n = 10) # rubocop:disable Metrics/MethodLength,Naming/MethodParameterName
|
412
|
+
keys_with_touches = []
|
413
|
+
|
414
|
+
buckets.times do |idx|
|
415
|
+
mutex = @mutexes[idx]
|
416
|
+
store = @stores[idx]
|
417
|
+
|
418
|
+
mutex.synchronize do
|
419
|
+
store.each do |key, entry|
|
420
|
+
keys_with_touches << [key, entry[:touches] || 0]
|
421
|
+
end
|
422
|
+
end
|
423
|
+
end
|
424
|
+
|
425
|
+
keys_with_touches.sort_by { |_, count| count }.first(n)
|
426
|
+
end
|
427
|
+
|
428
|
+
# Returns total memory used across all buckets
|
429
|
+
def current_memory_bytes
|
430
|
+
@current_bytes.sum
|
431
|
+
end
|
432
|
+
|
433
|
+
# Returns configured maximum memory allowed
|
434
|
+
def max_memory_bytes
|
435
|
+
@max_bytes
|
436
|
+
end
|
437
|
+
|
438
|
+
# Executes a block with a specific namespace, restoring the old namespace afterwards
|
439
|
+
def with_namespace(namespace)
|
440
|
+
old_ns = Thread.current[:mudis_namespace]
|
441
|
+
Thread.current[:mudis_namespace] = namespace
|
442
|
+
yield
|
443
|
+
ensure
|
444
|
+
Thread.current[:mudis_namespace] = old_ns
|
445
|
+
end
|
446
|
+
|
447
|
+
private
|
448
|
+
|
449
|
+
# Decompresses and deserializes a raw value
|
450
|
+
def decompress_and_deserialize(raw)
|
451
|
+
val = compress ? Zlib::Inflate.inflate(raw) : raw
|
452
|
+
serializer.load(val)
|
453
|
+
end
|
454
|
+
|
455
|
+
# Thread-safe metric increment
|
456
|
+
def metric(name)
|
457
|
+
@metrics_mutex.synchronize { @metrics[name] += 1 }
|
458
|
+
end
|
459
|
+
|
460
|
+
# Removes a key from storage and LRU
|
461
|
+
def evict_key(idx, key)
|
462
|
+
store = @stores[idx]
|
463
|
+
entry = store.delete(key)
|
464
|
+
return unless entry
|
465
|
+
|
466
|
+
@current_bytes[idx] -= (key.bytesize + entry[:value].bytesize)
|
467
|
+
|
468
|
+
node = @lru_nodes[idx].delete(key)
|
469
|
+
remove_node(idx, node) if node
|
470
|
+
end
|
471
|
+
|
472
|
+
# Inserts a key at the head of the LRU list
|
473
|
+
def insert_lru(idx, key)
|
474
|
+
node = LRUNode.new(key)
|
475
|
+
node.next = @lru_heads[idx]
|
476
|
+
@lru_heads[idx].prev = node if @lru_heads[idx]
|
477
|
+
@lru_heads[idx] = node
|
478
|
+
@lru_tails[idx] ||= node
|
479
|
+
@lru_nodes[idx][key] = node
|
480
|
+
end
|
481
|
+
|
482
|
+
# Promotes a key to the front of the LRU list
|
483
|
+
def promote_lru(idx, key)
|
484
|
+
node = @lru_nodes[idx][key]
|
485
|
+
return unless node && @lru_heads[idx] != node
|
486
|
+
|
487
|
+
remove_node(idx, node)
|
488
|
+
insert_lru(idx, key)
|
489
|
+
end
|
490
|
+
|
491
|
+
# Removes a node from the LRU list
|
492
|
+
def remove_node(idx, node)
|
493
|
+
if node.prev
|
494
|
+
node.prev.next = node.next
|
495
|
+
else
|
496
|
+
@lru_heads[idx] = node.next
|
497
|
+
end
|
498
|
+
|
499
|
+
if node.next
|
500
|
+
node.next.prev = node.prev
|
501
|
+
else
|
502
|
+
@lru_tails[idx] = node.prev
|
503
|
+
end
|
504
|
+
end
|
505
|
+
|
506
|
+
# Namespaces a key with an optional namespace
|
507
|
+
def namespaced_key(key, namespace = nil)
|
508
|
+
ns = namespace || Thread.current[:mudis_namespace]
|
509
|
+
ns ? "#{ns}:#{key}" : key
|
510
|
+
end
|
511
|
+
|
512
|
+
# Calculates the effective TTL for an entry, respecting max_ttl if set
|
513
|
+
def effective_ttl(expires_in)
|
514
|
+
ttl = expires_in || @default_ttl
|
515
|
+
return nil unless ttl
|
516
|
+
return ttl unless @max_ttl
|
517
|
+
|
518
|
+
[ttl, @max_ttl].min
|
519
|
+
end
|
520
|
+
end
|
521
|
+
end
|