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