mudis 0.7.3 → 0.8.1

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.
data/lib/mudis.rb CHANGED
@@ -1,521 +1,640 @@
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
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
+ @persistence_enabled = config.persistence_enabled
51
+ @persistence_path = config.persistence_path
52
+ @persistence_format = config.persistence_format
53
+ @persistence_safe_write = config.persistence_safe_write
54
+
55
+ if config.buckets
56
+ @buckets = config.buckets
57
+ reset!
58
+ end
59
+
60
+ return unless @persistence_enabled
61
+
62
+ install_persistence_hook!
63
+ end
64
+
65
+ # Validates the current configuration, raising errors for invalid settings
66
+ def validate_config! # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
67
+ if config.max_value_bytes && config.max_value_bytes > config.max_bytes
68
+ raise ArgumentError,
69
+ "max_value_bytes cannot exceed max_bytes"
70
+ end
71
+
72
+ raise ArgumentError, "max_value_bytes must be > 0" if config.max_value_bytes && config.max_value_bytes <= 0
73
+
74
+ raise ArgumentError, "buckets must be > 0" if config.buckets && config.buckets <= 0
75
+ raise ArgumentError, "max_ttl must be > 0" if config.max_ttl && config.max_ttl <= 0
76
+ raise ArgumentError, "default_ttl must be > 0" if config.default_ttl && config.default_ttl <= 0
77
+ end
78
+
79
+ # Returns a snapshot of metrics (thread-safe)
80
+ def metrics # rubocop:disable Metrics/MethodLength
81
+ @metrics_mutex.synchronize do
82
+ {
83
+ hits: @metrics[:hits],
84
+ misses: @metrics[:misses],
85
+ evictions: @metrics[:evictions],
86
+ rejected: @metrics[:rejected],
87
+ total_memory: current_memory_bytes,
88
+ least_touched: least_touched(10),
89
+ buckets: buckets.times.map do |idx|
90
+ {
91
+ index: idx,
92
+ keys: @stores[idx].size,
93
+ memory_bytes: @current_bytes[idx],
94
+ lru_size: @lru_nodes[idx].size
95
+ }
96
+ end
97
+ }
98
+ end
99
+ end
100
+
101
+ # Resets metric counters (thread-safe)
102
+ def reset_metrics!
103
+ @metrics_mutex.synchronize do
104
+ @metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 }
105
+ end
106
+ end
107
+
108
+ # Fully resets all internal state (except config)
109
+ def reset!
110
+ stop_expiry_thread
111
+
112
+ @buckets = nil
113
+ b = buckets
114
+
115
+ @stores = Array.new(b) { {} }
116
+ @mutexes = Array.new(b) { Mutex.new }
117
+ @lru_heads = Array.new(b) { nil }
118
+ @lru_tails = Array.new(b) { nil }
119
+ @lru_nodes = Array.new(b) { {} }
120
+ @current_bytes = Array.new(b, 0)
121
+
122
+ reset_metrics!
123
+ end
124
+
125
+ # Sets the maximum size for a single value in bytes
126
+ def max_bytes=(value)
127
+ raise ArgumentError, "max_bytes must be > 0" if value.to_i <= 0
128
+
129
+ @max_bytes = value
130
+ @threshold_bytes = (@max_bytes * 0.9).to_i
131
+ end
132
+
133
+ # Sets the maximum size for a single value in bytes, raising an error if invalid
134
+ def max_value_bytes=(value)
135
+ raise ArgumentError, "max_value_bytes must be > 0" if value && value.to_i <= 0
136
+
137
+ @max_value_bytes = value
138
+ end
139
+ end
140
+
141
+ # Node structure for the LRU doubly-linked list
142
+ class LRUNode
143
+ attr_accessor :key, :prev, :next
144
+
145
+ def initialize(key)
146
+ @key = key
147
+ @prev = nil
148
+ @next = nil
149
+ end
150
+ end
151
+
152
+ # Number of cache buckets (shards). Default: 32
153
+ def self.buckets
154
+ return @buckets if @buckets
155
+
156
+ val = config.buckets || ENV["MUDIS_BUCKETS"]&.to_i || 32
157
+ raise ArgumentError, "bucket count must be > 0" if val <= 0
158
+
159
+ @buckets = val
160
+ end
161
+
162
+ # --- Internal Structures ---
163
+
164
+ @stores = Array.new(buckets) { {} } # Array of hash buckets for storage
165
+ @mutexes = Array.new(buckets) { Mutex.new } # Per-bucket mutexes
166
+ @lru_heads = Array.new(buckets) { nil } # Head node for each LRU list
167
+ @lru_tails = Array.new(buckets) { nil } # Tail node for each LRU list
168
+ @lru_nodes = Array.new(buckets) { {} } # Map of key => LRU node
169
+ @current_bytes = Array.new(buckets, 0) # Memory usage per bucket
170
+ @max_bytes = 1_073_741_824 # 1 GB global max cache size
171
+ @threshold_bytes = (@max_bytes * 0.9).to_i # Eviction threshold at 90%
172
+ @expiry_thread = nil # Background thread for expiry cleanup
173
+ @hard_memory_limit = false # Whether to enforce hard memory cap
174
+
175
+ class << self
176
+ # Starts a thread that periodically removes expired entries
177
+ def start_expiry_thread(interval: 60)
178
+ return if @expiry_thread&.alive?
179
+
180
+ @stop_expiry = false
181
+ @expiry_thread = Thread.new do
182
+ loop do
183
+ break if @stop_expiry
184
+
185
+ sleep interval
186
+ cleanup_expired!
187
+ end
188
+ end
189
+ end
190
+
191
+ # Signals and joins the expiry thread
192
+ def stop_expiry_thread
193
+ @stop_expiry = true
194
+ @expiry_thread&.join
195
+ @expiry_thread = nil
196
+ end
197
+
198
+ # Computes which bucket a key belongs to
199
+ def bucket_index(key)
200
+ key.hash % buckets
201
+ end
202
+
203
+ # Checks if a key exists and is not expired
204
+ def exists?(key, namespace: nil)
205
+ key = namespaced_key(key, namespace)
206
+ !!read(key)
207
+ end
208
+
209
+ # Reads and returns the value for a key, updating LRU and metrics
210
+ def read(key, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
211
+ key = namespaced_key(key, namespace)
212
+ raw_entry = nil
213
+ idx = bucket_index(key)
214
+ mutex = @mutexes[idx]
215
+ store = @stores[idx]
216
+
217
+ mutex.synchronize do
218
+ raw_entry = @stores[idx][key]
219
+ if raw_entry && raw_entry[:expires_at] && Time.now > raw_entry[:expires_at]
220
+ evict_key(idx, key)
221
+ raw_entry = nil
222
+ end
223
+
224
+ store[key][:touches] = (store[key][:touches] || 0) + 1 if store[key]
225
+
226
+ metric(:hits) if raw_entry
227
+ metric(:misses) unless raw_entry
228
+ end
229
+
230
+ return nil unless raw_entry
231
+
232
+ value = decompress_and_deserialize(raw_entry[:value])
233
+ promote_lru(idx, key)
234
+ value
235
+ end
236
+
237
+ # Writes a value to the cache with optional expiry and LRU tracking
238
+ def write(key, value, expires_in: nil, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
239
+ key = namespaced_key(key, namespace)
240
+ raw = serializer.dump(value)
241
+ raw = Zlib::Deflate.deflate(raw) if compress
242
+ size = key.bytesize + raw.bytesize
243
+ return if max_value_bytes && raw.bytesize > max_value_bytes
244
+
245
+ if hard_memory_limit && current_memory_bytes + size > max_memory_bytes
246
+ metric(:rejected)
247
+ return
248
+ end
249
+
250
+ # Ensure expires_in respects max_ttl and default_ttl
251
+ expires_in = effective_ttl(expires_in)
252
+
253
+ idx = bucket_index(key)
254
+ mutex = @mutexes[idx]
255
+ store = @stores[idx]
256
+
257
+ mutex.synchronize do
258
+ evict_key(idx, key) if store[key]
259
+
260
+ while @current_bytes[idx] + size > (@threshold_bytes / buckets) && @lru_tails[idx]
261
+ evict_key(idx, @lru_tails[idx].key)
262
+ metric(:evictions)
263
+ end
264
+
265
+ store[key] = {
266
+ value: raw,
267
+ expires_at: expires_in ? Time.now + expires_in : nil,
268
+ created_at: Time.now,
269
+ touches: 0
270
+ }
271
+
272
+ insert_lru(idx, key)
273
+ @current_bytes[idx] += size
274
+ end
275
+ end
276
+
277
+ # Atomically updates the value for a key using a block
278
+ def update(key, namespace: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
279
+ key = namespaced_key(key, namespace)
280
+ idx = bucket_index(key)
281
+ mutex = @mutexes[idx]
282
+ store = @stores[idx]
283
+
284
+ raw_entry = nil
285
+ mutex.synchronize do
286
+ raw_entry = store[key]
287
+ return nil unless raw_entry
288
+ end
289
+
290
+ value = decompress_and_deserialize(raw_entry[:value])
291
+ new_value = yield(value)
292
+ new_raw = serializer.dump(new_value)
293
+ new_raw = Zlib::Deflate.deflate(new_raw) if compress
294
+
295
+ mutex.synchronize do
296
+ old_size = key.bytesize + raw_entry[:value].bytesize
297
+ new_size = key.bytesize + new_raw.bytesize
298
+ store[key][:value] = new_raw
299
+ @current_bytes[idx] += (new_size - old_size)
300
+ promote_lru(idx, key)
301
+ end
302
+ end
303
+
304
+ # Deletes a key from the cache
305
+ def delete(key, namespace: nil)
306
+ key = namespaced_key(key, namespace)
307
+ idx = bucket_index(key)
308
+ mutex = @mutexes[idx]
309
+
310
+ mutex.synchronize do
311
+ evict_key(idx, key)
312
+ end
313
+ end
314
+
315
+ # Fetches a value for a key, writing it if not present or expired
316
+ # The block is executed to generate the value if it doesn't exist
317
+ # Optionally accepts an expiration time
318
+ # If force is true, it always fetches and writes the value
319
+ def fetch(key, expires_in: nil, force: false, namespace: nil)
320
+ key = namespaced_key(key, namespace)
321
+ unless force
322
+ cached = read(key)
323
+ return cached if cached
324
+ end
325
+
326
+ value = yield
327
+ write(key, value, expires_in: expires_in)
328
+ value
329
+ end
330
+
331
+ # Clears a specific key from the cache, a semantic synonym for delete
332
+ # This method is provided for clarity in usage
333
+ # It behaves the same as delete
334
+ def clear(key, namespace: nil)
335
+ delete(key, namespace: namespace)
336
+ end
337
+
338
+ # Replaces the value for a key if it exists, otherwise does nothing
339
+ # This is useful for updating values without needing to check existence first
340
+ # It will write the new value and update the expiration if provided
341
+ # If the key does not exist, it will not create a new entry
342
+ def replace(key, value, expires_in: nil, namespace: nil)
343
+ return unless exists?(key, namespace: namespace)
344
+
345
+ write(key, value, expires_in: expires_in, namespace: namespace)
346
+ end
347
+
348
+ # Inspects a key and returns all meta data for it
349
+ def inspect(key, namespace: nil) # rubocop:disable Metrics/MethodLength
350
+ key = namespaced_key(key, namespace)
351
+ idx = bucket_index(key)
352
+ store = @stores[idx]
353
+ mutex = @mutexes[idx]
354
+
355
+ mutex.synchronize do
356
+ entry = store[key]
357
+ return nil unless entry
358
+
359
+ {
360
+ key: key,
361
+ bucket: idx,
362
+ expires_at: entry[:expires_at],
363
+ created_at: entry[:created_at],
364
+ size_bytes: key.bytesize + entry[:value].bytesize,
365
+ compressed: compress
366
+ }
367
+ end
368
+ end
369
+
370
+ # Removes expired keys across all buckets
371
+ def cleanup_expired!
372
+ now = Time.now
373
+ buckets.times do |idx|
374
+ mutex = @mutexes[idx]
375
+ store = @stores[idx]
376
+ mutex.synchronize do
377
+ store.keys.each do |key| # rubocop:disable Style/HashEachMethods
378
+ evict_key(idx, key) if store[key][:expires_at] && now > store[key][:expires_at]
379
+ end
380
+ end
381
+ end
382
+ end
383
+
384
+ # Returns an array of all cache keys
385
+ def all_keys
386
+ keys = []
387
+ buckets.times do |idx|
388
+ mutex = @mutexes[idx]
389
+ store = @stores[idx]
390
+ mutex.synchronize { keys.concat(store.keys) }
391
+ end
392
+ keys
393
+ end
394
+
395
+ # Returns all keys in a specific namespace
396
+ def keys(namespace:)
397
+ raise ArgumentError, "namespace is required" unless namespace
398
+
399
+ prefix = "#{namespace}:"
400
+ all_keys.select { |key| key.start_with?(prefix) }.map { |key| key.delete_prefix(prefix) }
401
+ end
402
+
403
+ # Clears all keys in a specific namespace
404
+ def clear_namespace(namespace:)
405
+ raise ArgumentError, "namespace is required" unless namespace
406
+
407
+ prefix = "#{namespace}:"
408
+ buckets.times do |idx|
409
+ mutex = @mutexes[idx]
410
+ store = @stores[idx]
411
+
412
+ mutex.synchronize do
413
+ keys_to_delete = store.keys.select { |key| key.start_with?(prefix) }
414
+ keys_to_delete.each { |key| evict_key(idx, key) }
415
+ end
416
+ end
417
+ end
418
+
419
+ # Returns the least-touched keys across all buckets
420
+ def least_touched(n = 10) # rubocop:disable Metrics/MethodLength,Naming/MethodParameterName
421
+ keys_with_touches = []
422
+
423
+ buckets.times do |idx|
424
+ mutex = @mutexes[idx]
425
+ store = @stores[idx]
426
+
427
+ mutex.synchronize do
428
+ store.each do |key, entry|
429
+ keys_with_touches << [key, entry[:touches] || 0]
430
+ end
431
+ end
432
+ end
433
+
434
+ keys_with_touches.sort_by { |_, count| count }.first(n)
435
+ end
436
+
437
+ # Returns total memory used across all buckets
438
+ def current_memory_bytes
439
+ @current_bytes.sum
440
+ end
441
+
442
+ # Returns configured maximum memory allowed
443
+ def max_memory_bytes
444
+ @max_bytes
445
+ end
446
+
447
+ # Executes a block with a specific namespace, restoring the old namespace afterwards
448
+ def with_namespace(namespace)
449
+ old_ns = Thread.current[:mudis_namespace]
450
+ Thread.current[:mudis_namespace] = namespace
451
+ yield
452
+ ensure
453
+ Thread.current[:mudis_namespace] = old_ns
454
+ end
455
+
456
+ private
457
+
458
+ # Decompresses and deserializes a raw value
459
+ def decompress_and_deserialize(raw)
460
+ val = compress ? Zlib::Inflate.inflate(raw) : raw
461
+ serializer.load(val)
462
+ end
463
+
464
+ # Thread-safe metric increment
465
+ def metric(name)
466
+ @metrics_mutex.synchronize { @metrics[name] += 1 }
467
+ end
468
+
469
+ # Removes a key from storage and LRU
470
+ def evict_key(idx, key)
471
+ store = @stores[idx]
472
+ entry = store.delete(key)
473
+ return unless entry
474
+
475
+ @current_bytes[idx] -= (key.bytesize + entry[:value].bytesize)
476
+
477
+ node = @lru_nodes[idx].delete(key)
478
+ remove_node(idx, node) if node
479
+ end
480
+
481
+ # Inserts a key at the head of the LRU list
482
+ def insert_lru(idx, key)
483
+ node = LRUNode.new(key)
484
+ node.next = @lru_heads[idx]
485
+ @lru_heads[idx].prev = node if @lru_heads[idx]
486
+ @lru_heads[idx] = node
487
+ @lru_tails[idx] ||= node
488
+ @lru_nodes[idx][key] = node
489
+ end
490
+
491
+ # Promotes a key to the front of the LRU list
492
+ def promote_lru(idx, key)
493
+ node = @lru_nodes[idx][key]
494
+ return unless node && @lru_heads[idx] != node
495
+
496
+ remove_node(idx, node)
497
+ insert_lru(idx, key)
498
+ end
499
+
500
+ # Removes a node from the LRU list
501
+ def remove_node(idx, node)
502
+ if node.prev
503
+ node.prev.next = node.next
504
+ else
505
+ @lru_heads[idx] = node.next
506
+ end
507
+
508
+ if node.next
509
+ node.next.prev = node.prev
510
+ else
511
+ @lru_tails[idx] = node.prev
512
+ end
513
+ end
514
+
515
+ # Namespaces a key with an optional namespace
516
+ def namespaced_key(key, namespace = nil)
517
+ ns = namespace || Thread.current[:mudis_namespace]
518
+ ns ? "#{ns}:#{key}" : key
519
+ end
520
+
521
+ # Calculates the effective TTL for an entry, respecting max_ttl if set
522
+ def effective_ttl(expires_in)
523
+ ttl = expires_in || @default_ttl
524
+ return nil unless ttl
525
+ return ttl unless @max_ttl
526
+
527
+ [ttl, @max_ttl].min
528
+ end
529
+ end
530
+
531
+ class << self
532
+ # Saves the current cache state to disk for persistence
533
+ def save_snapshot!
534
+ return unless @persistence_enabled
535
+
536
+ data = snapshot_dump
537
+ safe_write_snapshot(data)
538
+ rescue StandardError => e
539
+ warn "[Mudis] Failed to save snapshot: #{e.class}: #{e.message}"
540
+ end
541
+
542
+ # Loads the cache state from disk for persistence
543
+ def load_snapshot!
544
+ return unless @persistence_enabled
545
+ return unless File.exist?(@persistence_path)
546
+
547
+ data = read_snapshot
548
+ snapshot_restore(data)
549
+ rescue StandardError => e
550
+ warn "[Mudis] Failed to load snapshot: #{e.class}: #{e.message}"
551
+ end
552
+
553
+ # Installs an at_exit hook to save the snapshot on process exit
554
+ def install_persistence_hook!
555
+ return unless @persistence_enabled
556
+ return if defined?(@persistence_hook_installed) && @persistence_hook_installed
557
+
558
+ at_exit { save_snapshot! }
559
+ @persistence_hook_installed = true
560
+ end
561
+ end
562
+
563
+ class << self
564
+ private
565
+
566
+ # Collect a JSON/Marshal-safe array of { key, value, expires_in }
567
+ def snapshot_dump # rubocop:disable Metrics/MethodLength
568
+ entries = []
569
+ now = Time.now
570
+ @buckets.times do |idx|
571
+ mutex = @mutexes[idx]
572
+ store = @stores[idx]
573
+ mutex.synchronize do
574
+ store.each do |key, raw|
575
+ exp_at = raw[:expires_at]
576
+ next if exp_at && now > exp_at
577
+
578
+ value = decompress_and_deserialize(raw[:value])
579
+ expires_in = exp_at ? (exp_at - now).to_i : nil
580
+ entries << { key: key, value: value, expires_in: expires_in }
581
+ end
582
+ end
583
+ end
584
+ entries
585
+ end
586
+
587
+ # Restore via existing write-path so LRU/limits/compression/TTL are honored
588
+ def snapshot_restore(entries)
589
+ return unless entries && !entries.empty?
590
+
591
+ entries.each do |e|
592
+ begin # rubocop:disable Style/RedundantBegin
593
+ write(e[:key], e[:value], expires_in: e[:expires_in])
594
+ rescue StandardError => ex
595
+ warn "[Mudis] Failed to restore key #{e[:key].inspect}: #{ex.message}"
596
+ end
597
+ end
598
+ end
599
+
600
+ # Serializer for snapshot persistence
601
+ # Defaults to Marshal if not JSON
602
+ def serializer_for_snapshot
603
+ (@persistence_format || :marshal).to_sym == :json ? JSON : :marshal
604
+ end
605
+
606
+ # Safely writes snapshot data to disk
607
+ # Uses safe write if configured
608
+ def safe_write_snapshot(data) # rubocop:disable Metrics/MethodLength
609
+ path = @persistence_path
610
+ dir = File.dirname(path)
611
+ Dir.mkdir(dir) unless Dir.exist?(dir)
612
+
613
+ payload =
614
+ if (@persistence_format || :marshal).to_sym == :json
615
+ serializer_for_snapshot.dump(data)
616
+ else
617
+ Marshal.dump(data)
618
+ end
619
+
620
+ if @persistence_safe_write
621
+ tmp = "#{path}.tmp-#{$$}-#{Thread.current.object_id}"
622
+ File.open(tmp, "wb") { |f| f.write(payload) }
623
+ File.rename(tmp, path)
624
+ else
625
+ File.open(path, "wb") { |f| f.write(payload) }
626
+ end
627
+ end
628
+
629
+ # Reads snapshot data from disk
630
+ # Uses safe read if configured
631
+ def read_snapshot
632
+ if (@persistence_format || :marshal).to_sym == :json
633
+ serializer_for_snapshot.load(File.binread(@persistence_path))
634
+ else
635
+ ## safe to use Marshal here as we control the file
636
+ Marshal.load(File.binread(@persistence_path)) # rubocop:disable Security/MarshalLoad
637
+ end
638
+ end
639
+ end
640
+ end