mudis 0.8.0 → 0.9.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.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Mudis
4
+ # Expiry module handles TTL-based expiration and background cleanup
5
+ module Expiry
6
+ # Starts a thread that periodically removes expired entries
7
+ def start_expiry_thread(interval: 60)
8
+ return if @expiry_thread&.alive?
9
+
10
+ @stop_expiry = false
11
+ @expiry_thread = Thread.new do
12
+ loop do
13
+ break if @stop_expiry
14
+
15
+ sleep interval
16
+ cleanup_expired!
17
+ end
18
+ end
19
+ end
20
+
21
+ # Signals and joins the expiry thread
22
+ def stop_expiry_thread
23
+ @stop_expiry = true
24
+ @expiry_thread&.join
25
+ @expiry_thread = nil
26
+ end
27
+
28
+ # Removes expired keys across all buckets
29
+ def cleanup_expired!
30
+ now = Time.now
31
+ buckets.times do |idx|
32
+ mutex = @mutexes[idx]
33
+ store = @stores[idx]
34
+ mutex.synchronize do
35
+ store.keys.each do |key| # rubocop:disable Style/HashEachMethods
36
+ evict_key(idx, key) if store[key][:expires_at] && now > store[key][:expires_at]
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # Calculates the effective TTL for an entry, respecting max_ttl if set
45
+ def effective_ttl(expires_in)
46
+ ttl = expires_in || @default_ttl
47
+ return nil unless ttl
48
+ return ttl unless @max_ttl
49
+
50
+ [ttl, @max_ttl].min
51
+ end
52
+ end
53
+ end
data/lib/mudis/lru.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Mudis
4
+ # Node structure for the LRU doubly-linked list
5
+ class LRUNode
6
+ attr_accessor :key, :prev, :next
7
+
8
+ def initialize(key)
9
+ @key = key
10
+ @prev = nil
11
+ @next = nil
12
+ end
13
+ end
14
+
15
+ # LRU module handles the Least Recently Used eviction strategy
16
+ # Maintains doubly-linked lists per bucket for O(1) promote/evict operations
17
+ module LRU
18
+ private
19
+
20
+ # Removes a key from storage and LRU
21
+ def evict_key(idx, key)
22
+ store = @stores[idx]
23
+ entry = store.delete(key)
24
+ return unless entry
25
+
26
+ @current_bytes[idx] -= (key.bytesize + entry[:value].bytesize)
27
+
28
+ node = @lru_nodes[idx].delete(key)
29
+ remove_node(idx, node) if node
30
+ end
31
+
32
+ # Inserts a key at the head of the LRU list
33
+ def insert_lru(idx, key)
34
+ node = LRUNode.new(key)
35
+ node.next = @lru_heads[idx]
36
+ @lru_heads[idx].prev = node if @lru_heads[idx]
37
+ @lru_heads[idx] = node
38
+ @lru_tails[idx] ||= node
39
+ @lru_nodes[idx][key] = node
40
+ end
41
+
42
+ # Promotes a key to the front of the LRU list
43
+ def promote_lru(idx, key)
44
+ node = @lru_nodes[idx][key]
45
+ return unless node && @lru_heads[idx] != node
46
+
47
+ remove_node(idx, node)
48
+ insert_lru(idx, key)
49
+ end
50
+
51
+ # Removes a node from the LRU list
52
+ def remove_node(idx, node)
53
+ if node.prev
54
+ node.prev.next = node.next
55
+ else
56
+ @lru_heads[idx] = node.next
57
+ end
58
+
59
+ if node.next
60
+ node.next.prev = node.prev
61
+ else
62
+ @lru_tails[idx] = node.prev
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Mudis
4
+ # Metrics module handles tracking of cache hits, misses, evictions and memory usage
5
+ module Metrics
6
+ # Returns a snapshot of metrics (thread-safe)
7
+ def metrics # rubocop:disable Metrics/MethodLength
8
+ @metrics_mutex.synchronize do
9
+ {
10
+ hits: @metrics[:hits],
11
+ misses: @metrics[:misses],
12
+ evictions: @metrics[:evictions],
13
+ rejected: @metrics[:rejected],
14
+ total_memory: current_memory_bytes,
15
+ least_touched: least_touched(10),
16
+ buckets: buckets.times.map do |idx|
17
+ {
18
+ index: idx,
19
+ keys: @stores[idx].size,
20
+ memory_bytes: @current_bytes[idx],
21
+ lru_size: @lru_nodes[idx].size
22
+ }
23
+ end
24
+ }
25
+ end
26
+ end
27
+
28
+ # Resets metric counters (thread-safe)
29
+ def reset_metrics!
30
+ @metrics_mutex.synchronize do
31
+ @metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 }
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Thread-safe metric increment
38
+ def metric(name)
39
+ @metrics_mutex.synchronize { @metrics[name] += 1 }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Mudis
4
+ # Namespace module handles logical key separation and scoping
5
+ module Namespace
6
+ # Returns all keys in a specific namespace
7
+ def keys(namespace:)
8
+ raise ArgumentError, "namespace is required" unless namespace
9
+
10
+ prefix = "#{namespace}:"
11
+ all_keys.select { |key| key.start_with?(prefix) }.map { |key| key.delete_prefix(prefix) }
12
+ end
13
+
14
+ # Clears all keys in a specific namespace
15
+ def clear_namespace(namespace:)
16
+ raise ArgumentError, "namespace is required" unless namespace
17
+
18
+ prefix = "#{namespace}:"
19
+ buckets.times do |idx|
20
+ mutex = @mutexes[idx]
21
+ store = @stores[idx]
22
+
23
+ mutex.synchronize do
24
+ keys_to_delete = store.keys.select { |key| key.start_with?(prefix) }
25
+ keys_to_delete.each { |key| evict_key(idx, key) }
26
+ end
27
+ end
28
+ end
29
+
30
+ # Executes a block with a specific namespace, restoring the old namespace afterwards
31
+ def with_namespace(namespace)
32
+ old_ns = Thread.current[:mudis_namespace]
33
+ Thread.current[:mudis_namespace] = namespace
34
+ yield
35
+ ensure
36
+ Thread.current[:mudis_namespace] = old_ns
37
+ end
38
+
39
+ private
40
+
41
+ # Namespaces a key with an optional namespace
42
+ def namespaced_key(key, namespace = nil)
43
+ ns = namespace || Thread.current[:mudis_namespace]
44
+ ns ? "#{ns}:#{key}" : key
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Mudis
4
+ # Persistence module handles snapshot save/load operations for warm boot support
5
+ module Persistence
6
+ # Saves the current cache state to disk for persistence
7
+ def save_snapshot!
8
+ return unless @persistence_enabled
9
+
10
+ data = snapshot_dump
11
+ safe_write_snapshot(data)
12
+ rescue StandardError => e
13
+ warn "[Mudis] Failed to save snapshot: #{e.class}: #{e.message}"
14
+ end
15
+
16
+ # Loads the cache state from disk for persistence
17
+ def load_snapshot!
18
+ return unless @persistence_enabled
19
+ return unless File.exist?(@persistence_path)
20
+
21
+ data = read_snapshot
22
+ snapshot_restore(data)
23
+ rescue StandardError => e
24
+ warn "[Mudis] Failed to load snapshot: #{e.class}: #{e.message}"
25
+ end
26
+
27
+ # Installs an at_exit hook to save the snapshot on process exit
28
+ def install_persistence_hook!
29
+ return unless @persistence_enabled
30
+ return if defined?(@persistence_hook_installed) && @persistence_hook_installed
31
+
32
+ at_exit { save_snapshot! }
33
+ @persistence_hook_installed = true
34
+ end
35
+
36
+ private
37
+
38
+ # Collect a JSON/Marshal-safe array of { key, value, expires_in }
39
+ def snapshot_dump # rubocop:disable Metrics/MethodLength
40
+ entries = []
41
+ now = Time.now
42
+ @buckets.times do |idx|
43
+ mutex = @mutexes[idx]
44
+ store = @stores[idx]
45
+ mutex.synchronize do
46
+ store.each do |key, raw|
47
+ exp_at = raw[:expires_at]
48
+ next if exp_at && now > exp_at
49
+
50
+ value = decompress_and_deserialize(raw[:value])
51
+ expires_in = exp_at ? (exp_at - now).to_i : nil
52
+ entries << { key: key, value: value, expires_in: expires_in }
53
+ end
54
+ end
55
+ end
56
+ entries
57
+ end
58
+
59
+ # Restore via existing write-path so LRU/limits/compression/TTL are honored
60
+ def snapshot_restore(entries)
61
+ return unless entries && !entries.empty?
62
+
63
+ entries.each do |e|
64
+ begin # rubocop:disable Style/RedundantBegin
65
+ write(e[:key], e[:value], expires_in: e[:expires_in])
66
+ rescue StandardError => ex
67
+ warn "[Mudis] Failed to restore key #{e[:key].inspect}: #{ex.message}"
68
+ end
69
+ end
70
+ end
71
+
72
+ # Serializer for snapshot persistence
73
+ # Defaults to Marshal if not JSON
74
+ def serializer_for_snapshot
75
+ (@persistence_format || :marshal).to_sym == :json ? JSON : :marshal
76
+ end
77
+
78
+ # Safely writes snapshot data to disk
79
+ # Uses safe write if configured
80
+ def safe_write_snapshot(data) # rubocop:disable Metrics/MethodLength
81
+ path = @persistence_path
82
+ dir = File.dirname(path)
83
+ Dir.mkdir(dir) unless Dir.exist?(dir)
84
+
85
+ payload =
86
+ if (@persistence_format || :marshal).to_sym == :json
87
+ serializer_for_snapshot.dump(data)
88
+ else
89
+ Marshal.dump(data)
90
+ end
91
+
92
+ if @persistence_safe_write
93
+ tmp = "#{path}.tmp-#{$$}-#{Thread.current.object_id}"
94
+ File.open(tmp, "wb") { |f| f.write(payload) }
95
+ File.rename(tmp, path)
96
+ else
97
+ File.open(path, "wb") { |f| f.write(payload) }
98
+ end
99
+ end
100
+
101
+ # Reads snapshot data from disk
102
+ # Uses safe read if configured
103
+ def read_snapshot
104
+ if (@persistence_format || :marshal).to_sym == :json
105
+ # Use JSON.parse instead of JSON.load to support symbolize_names option
106
+ serializer_for_snapshot.parse(File.binread(@persistence_path), symbolize_names: true)
107
+ else
108
+ ## safe to use Marshal here as we control the file
109
+ Marshal.load(File.binread(@persistence_path)) # rubocop:disable Security/MarshalLoad
110
+ end
111
+ end
112
+ end
113
+ end
data/lib/mudis/version.rb CHANGED
@@ -1,3 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
- MUDIS_VERSION = "0.8.0"
1
+ # frozen_string_literal: true
2
+
3
+ MUDIS_VERSION = "0.9.0"