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.
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 the least-touched keys across all buckets
387
- def least_touched(n = 10) # rubocop:disable Metrics/MethodLength,Naming/MethodParameterName
388
- keys_with_touches = []
389
-
390
- buckets.times do |idx|
391
- mutex = @mutexes[idx]
392
- store = @stores[idx]
393
-
394
- mutex.synchronize do
395
- store.each do |key, entry|
396
- keys_with_touches << [key, entry[:touches] || 0]
397
- end
398
- end
399
- end
400
-
401
- keys_with_touches.sort_by { |_, count| count }.first(n)
402
- end
403
-
404
- # Returns total memory used across all buckets
405
- def current_memory_bytes
406
- @current_bytes.sum
407
- end
408
-
409
- # Returns configured maximum memory allowed
410
- def max_memory_bytes
411
- @max_bytes
412
- end
413
-
414
- # Executes a block with a specific namespace, restoring the old namespace afterwards
415
- def with_namespace(namespace)
416
- old_ns = Thread.current[:mudis_namespace]
417
- Thread.current[:mudis_namespace] = namespace
418
- yield
419
- ensure
420
- Thread.current[:mudis_namespace] = old_ns
421
- end
422
-
423
- private
424
-
425
- # Decompresses and deserializes a raw value
426
- def decompress_and_deserialize(raw)
427
- val = compress ? Zlib::Inflate.inflate(raw) : raw
428
- serializer.load(val)
429
- end
430
-
431
- # Thread-safe metric increment
432
- def metric(name)
433
- @metrics_mutex.synchronize { @metrics[name] += 1 }
434
- end
435
-
436
- # Removes a key from storage and LRU
437
- def evict_key(idx, key)
438
- store = @stores[idx]
439
- entry = store.delete(key)
440
- return unless entry
441
-
442
- @current_bytes[idx] -= (key.bytesize + entry[:value].bytesize)
443
-
444
- node = @lru_nodes[idx].delete(key)
445
- remove_node(idx, node) if node
446
- end
447
-
448
- # Inserts a key at the head of the LRU list
449
- def insert_lru(idx, key)
450
- node = LRUNode.new(key)
451
- node.next = @lru_heads[idx]
452
- @lru_heads[idx].prev = node if @lru_heads[idx]
453
- @lru_heads[idx] = node
454
- @lru_tails[idx] ||= node
455
- @lru_nodes[idx][key] = node
456
- end
457
-
458
- # Promotes a key to the front of the LRU list
459
- def promote_lru(idx, key)
460
- node = @lru_nodes[idx][key]
461
- return unless node && @lru_heads[idx] != node
462
-
463
- remove_node(idx, node)
464
- insert_lru(idx, key)
465
- end
466
-
467
- # Removes a node from the LRU list
468
- def remove_node(idx, node)
469
- if node.prev
470
- node.prev.next = node.next
471
- else
472
- @lru_heads[idx] = node.next
473
- end
474
-
475
- if node.next
476
- node.next.prev = node.prev
477
- else
478
- @lru_tails[idx] = node.prev
479
- end
480
- end
481
-
482
- # Namespaces a key with an optional namespace
483
- def namespaced_key(key, namespace = nil)
484
- ns = namespace || Thread.current[:mudis_namespace]
485
- ns ? "#{ns}:#{key}" : key
486
- end
487
-
488
- # Calculates the effective TTL for an entry, respecting max_ttl if set
489
- def effective_ttl(expires_in)
490
- ttl = expires_in || @default_ttl
491
- return nil unless ttl
492
- return ttl unless @max_ttl
493
-
494
- [ttl, @max_ttl].min
495
- end
496
- end
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