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,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../spec_helper"
4
+
5
+ RSpec.describe Mudis::LRU do
6
+ let(:test_class) do
7
+ Class.new do
8
+ extend Mudis::LRU
9
+
10
+ @stores = Array.new(2) { {} }
11
+ @lru_heads = Array.new(2) { nil }
12
+ @lru_tails = Array.new(2) { nil }
13
+ @lru_nodes = Array.new(2) { {} }
14
+ @current_bytes = Array.new(2, 0)
15
+
16
+ class << self
17
+ attr_accessor :stores, :lru_heads, :lru_tails, :lru_nodes, :current_bytes
18
+ end
19
+ end
20
+ end
21
+
22
+ describe "LRUNode" do
23
+ it "initializes with a key" do
24
+ node = Mudis::LRUNode.new("test_key")
25
+
26
+ expect(node.key).to eq("test_key")
27
+ expect(node.prev).to be_nil
28
+ expect(node.next).to be_nil
29
+ end
30
+
31
+ it "allows setting prev and next" do
32
+ node1 = Mudis::LRUNode.new("key1")
33
+ node2 = Mudis::LRUNode.new("key2")
34
+
35
+ node1.next = node2
36
+ node2.prev = node1
37
+
38
+ expect(node1.next).to eq(node2)
39
+ expect(node2.prev).to eq(node1)
40
+ end
41
+ end
42
+
43
+ describe "#insert_lru (private)" do
44
+ it "inserts a node at the head of the LRU list" do
45
+ test_class.send(:insert_lru, 0, "key1")
46
+
47
+ expect(test_class.lru_heads[0]).to be_a(Mudis::LRUNode)
48
+ expect(test_class.lru_heads[0].key).to eq("key1")
49
+ expect(test_class.lru_tails[0]).to eq(test_class.lru_heads[0])
50
+ expect(test_class.lru_nodes[0]["key1"]).to be_a(Mudis::LRUNode)
51
+ end
52
+
53
+ it "maintains order when inserting multiple nodes" do
54
+ test_class.send(:insert_lru, 0, "key1")
55
+ test_class.send(:insert_lru, 0, "key2")
56
+ test_class.send(:insert_lru, 0, "key3")
57
+
58
+ expect(test_class.lru_heads[0].key).to eq("key3")
59
+ expect(test_class.lru_tails[0].key).to eq("key1")
60
+ end
61
+ end
62
+
63
+ describe "#promote_lru (private)" do
64
+ it "moves a node to the head of the LRU list" do
65
+ test_class.send(:insert_lru, 0, "key1")
66
+ test_class.send(:insert_lru, 0, "key2")
67
+ test_class.send(:insert_lru, 0, "key3")
68
+
69
+ test_class.send(:promote_lru, 0, "key1")
70
+
71
+ expect(test_class.lru_heads[0].key).to eq("key1")
72
+ end
73
+
74
+ it "does nothing if key is already at head" do
75
+ test_class.send(:insert_lru, 0, "key1")
76
+
77
+ head_before = test_class.lru_heads[0]
78
+ test_class.send(:promote_lru, 0, "key1")
79
+
80
+ expect(test_class.lru_heads[0].key).to eq("key1")
81
+ end
82
+
83
+ it "does nothing if node doesn't exist" do
84
+ expect { test_class.send(:promote_lru, 0, "nonexistent") }.not_to raise_error
85
+ end
86
+ end
87
+
88
+ describe "#remove_node (private)" do
89
+ it "removes a node from the middle of the list" do
90
+ test_class.send(:insert_lru, 0, "key1")
91
+ test_class.send(:insert_lru, 0, "key2")
92
+ test_class.send(:insert_lru, 0, "key3")
93
+
94
+ node = test_class.lru_nodes[0]["key2"]
95
+ test_class.send(:remove_node, 0, node)
96
+
97
+ expect(test_class.lru_heads[0].key).to eq("key3")
98
+ expect(test_class.lru_heads[0].next.key).to eq("key1")
99
+ end
100
+
101
+ it "updates head when removing head node" do
102
+ test_class.send(:insert_lru, 0, "key1")
103
+ test_class.send(:insert_lru, 0, "key2")
104
+
105
+ node = test_class.lru_heads[0]
106
+ test_class.send(:remove_node, 0, node)
107
+
108
+ expect(test_class.lru_heads[0].key).to eq("key1")
109
+ end
110
+
111
+ it "updates tail when removing tail node" do
112
+ test_class.send(:insert_lru, 0, "key1")
113
+ test_class.send(:insert_lru, 0, "key2")
114
+
115
+ node = test_class.lru_tails[0]
116
+ test_class.send(:remove_node, 0, node)
117
+
118
+ expect(test_class.lru_tails[0].key).to eq("key2")
119
+ end
120
+ end
121
+
122
+ describe "#evict_key (private)" do
123
+ it "removes key from store and LRU list" do
124
+ test_class.stores[0]["key1"] = { value: "test".b, expires_at: nil }
125
+ test_class.current_bytes[0] = 100
126
+ test_class.send(:insert_lru, 0, "key1")
127
+
128
+ test_class.send(:evict_key, 0, "key1")
129
+
130
+ expect(test_class.stores[0]).not_to have_key("key1")
131
+ expect(test_class.lru_nodes[0]).not_to have_key("key1")
132
+ expect(test_class.current_bytes[0]).to eq(92) # 100 - "key1".bytesize(4) - "test".bytesize(4)
133
+ end
134
+
135
+ it "does nothing if key doesn't exist" do
136
+ expect { test_class.send(:evict_key, 0, "nonexistent") }.not_to raise_error
137
+ end
138
+
139
+ it "updates memory counter correctly" do
140
+ test_class.stores[0]["mykey"] = { value: "myvalue".b, expires_at: nil }
141
+ test_class.current_bytes[0] = 100
142
+
143
+ test_class.send(:evict_key, 0, "mykey")
144
+
145
+ # 100 - ("mykey".bytesize + "myvalue".bytesize) = 100 - 12 = 88
146
+ expect(test_class.current_bytes[0]).to eq(88)
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../spec_helper"
4
+
5
+ RSpec.describe Mudis::Metrics do
6
+ let(:test_class) do
7
+ Class.new do
8
+ extend Mudis::Metrics
9
+
10
+ @metrics = { hits: 5, misses: 3, evictions: 2, rejected: 1 }
11
+ @metrics_mutex = Mutex.new
12
+ @buckets = 2
13
+ @stores = [{ "key1" => {} }, { "key2" => {} }]
14
+ @current_bytes = [100, 200]
15
+ @lru_nodes = [{ "key1" => nil }, { "key2" => nil }]
16
+
17
+ class << self
18
+ attr_accessor :metrics, :metrics_mutex, :buckets, :stores, :current_bytes, :lru_nodes
19
+
20
+ def current_memory_bytes
21
+ @current_bytes.sum
22
+ end
23
+
24
+ def least_touched(n)
25
+ [["key1", 5], ["key2", 10]]
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ describe "#metrics" do
32
+ it "returns a snapshot of current metrics" do
33
+ result = test_class.metrics
34
+
35
+ expect(result).to include(
36
+ hits: 5,
37
+ misses: 3,
38
+ evictions: 2,
39
+ rejected: 1
40
+ )
41
+ # total_memory and other derived stats depend on full Mudis context
42
+ end
43
+
44
+ it "includes least_touched keys via delegation" do
45
+ # This method delegates to least_touched which is defined in main class
46
+ # In integration tests this works, in isolation we just verify the structure
47
+ expect(test_class).to respond_to(:metrics)
48
+ end
49
+
50
+ it "includes per-bucket stats via delegation" do
51
+ # Bucket stats require full context from main class
52
+ # In integration tests this works, in isolation we just verify the method exists
53
+ expect(test_class).to respond_to(:metrics)
54
+ end
55
+
56
+ it "is thread-safe" do
57
+ threads = 10.times.map do
58
+ Thread.new { test_class.metrics }
59
+ end
60
+
61
+ expect { threads.each(&:join) }.not_to raise_error
62
+ end
63
+ end
64
+
65
+ describe "#reset_metrics!" do
66
+ it "resets all metric counters to zero" do
67
+ test_class.reset_metrics!
68
+
69
+ expect(test_class.metrics[:hits]).to eq(0)
70
+ expect(test_class.metrics[:misses]).to eq(0)
71
+ expect(test_class.metrics[:evictions]).to eq(0)
72
+ expect(test_class.metrics[:rejected]).to eq(0)
73
+ end
74
+
75
+ it "is thread-safe" do
76
+ threads = 10.times.map do
77
+ Thread.new { test_class.reset_metrics! }
78
+ end
79
+
80
+ expect { threads.each(&:join) }.not_to raise_error
81
+ end
82
+ end
83
+
84
+ describe "#metric (private)" do
85
+ it "increments metric counters" do
86
+ initial_hits = test_class.metrics[:hits]
87
+
88
+ test_class.send(:metric, :hits)
89
+
90
+ expect(test_class.metrics[:hits]).to eq(initial_hits + 1)
91
+ end
92
+
93
+ it "is thread-safe" do
94
+ test_class.reset_metrics!
95
+
96
+ threads = 100.times.map do
97
+ Thread.new { test_class.send(:metric, :hits) }
98
+ end
99
+
100
+ threads.each(&:join)
101
+
102
+ expect(test_class.metrics[:hits]).to eq(100)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../spec_helper"
4
+
5
+ RSpec.describe Mudis::Namespace do
6
+ let(:test_class) do
7
+ Class.new do
8
+ extend Mudis::Namespace
9
+
10
+ @buckets = 2
11
+ @mutexes = Array.new(2) { Mutex.new }
12
+ @stores = [
13
+ { "ns1:key1" => {}, "ns1:key2" => {}, "key3" => {} },
14
+ { "ns2:key1" => {}, "other" => {} }
15
+ ]
16
+ @lru_nodes = Array.new(2) { {} }
17
+ @current_bytes = Array.new(2, 0)
18
+
19
+ class << self
20
+ attr_accessor :buckets, :mutexes, :stores, :lru_nodes, :current_bytes
21
+
22
+ def all_keys
23
+ @stores.flat_map(&:keys)
24
+ end
25
+
26
+ def evict_key(idx, key)
27
+ @stores[idx].delete(key)
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ describe "#keys" do
34
+ it "returns all keys for a given namespace" do
35
+ keys = test_class.keys(namespace: "ns1")
36
+
37
+ expect(keys).to contain_exactly("key1", "key2")
38
+ end
39
+
40
+ it "returns empty array if no keys exist for namespace" do
41
+ keys = test_class.keys(namespace: "nonexistent")
42
+
43
+ expect(keys).to eq([])
44
+ end
45
+
46
+ it "raises error if namespace is nil" do
47
+ expect { test_class.keys(namespace: nil) }.to raise_error(ArgumentError, "namespace is required")
48
+ end
49
+
50
+ it "strips namespace prefix from returned keys" do
51
+ keys = test_class.keys(namespace: "ns2")
52
+
53
+ expect(keys).to eq(["key1"])
54
+ expect(keys).not_to include("ns2:key1")
55
+ end
56
+ end
57
+
58
+ describe "#clear_namespace" do
59
+ it "deletes all keys in a given namespace" do
60
+ test_class.clear_namespace(namespace: "ns1")
61
+
62
+ expect(test_class.stores[0]).not_to have_key("ns1:key1")
63
+ expect(test_class.stores[0]).not_to have_key("ns1:key2")
64
+ expect(test_class.stores[0]).to have_key("key3") # non-namespaced key remains
65
+ end
66
+
67
+ it "does nothing if namespace has no keys" do
68
+ expect { test_class.clear_namespace(namespace: "nonexistent") }.not_to raise_error
69
+ end
70
+
71
+ it "raises error if namespace is nil" do
72
+ expect { test_class.clear_namespace(namespace: nil) }.to raise_error(ArgumentError, "namespace is required")
73
+ end
74
+
75
+ it "only deletes keys with exact namespace prefix" do
76
+ test_class.stores[0]["ns1_similar"] = {}
77
+
78
+ test_class.clear_namespace(namespace: "ns1")
79
+
80
+ expect(test_class.stores[0]).to have_key("ns1_similar")
81
+ end
82
+ end
83
+
84
+ describe "#with_namespace" do
85
+ it "sets thread-local namespace for the block" do
86
+ test_class.with_namespace("test_ns") do
87
+ expect(Thread.current[:mudis_namespace]).to eq("test_ns")
88
+ end
89
+ end
90
+
91
+ it "restores previous namespace after block" do
92
+ Thread.current[:mudis_namespace] = "original"
93
+
94
+ test_class.with_namespace("temporary") do
95
+ # inside block
96
+ end
97
+
98
+ expect(Thread.current[:mudis_namespace]).to eq("original")
99
+ end
100
+
101
+ it "restores namespace even if block raises error" do
102
+ Thread.current[:mudis_namespace] = "original"
103
+
104
+ expect do
105
+ test_class.with_namespace("temporary") do
106
+ raise "test error"
107
+ end
108
+ end.to raise_error("test error")
109
+
110
+ expect(Thread.current[:mudis_namespace]).to eq("original")
111
+ end
112
+
113
+ it "returns the block's return value" do
114
+ result = test_class.with_namespace("test") do
115
+ "block_result"
116
+ end
117
+
118
+ expect(result).to eq("block_result")
119
+ end
120
+ end
121
+
122
+ describe "#namespaced_key (private)" do
123
+ it "prefixes key with namespace" do
124
+ result = test_class.send(:namespaced_key, "mykey", "mynamespace")
125
+
126
+ expect(result).to eq("mynamespace:mykey")
127
+ end
128
+
129
+ it "returns unprefixed key when namespace is nil" do
130
+ Thread.current[:mudis_namespace] = nil
131
+
132
+ result = test_class.send(:namespaced_key, "mykey", nil)
133
+
134
+ expect(result).to eq("mykey")
135
+ end
136
+
137
+ it "uses thread-local namespace when explicit namespace is nil" do
138
+ Thread.current[:mudis_namespace] = "thread_ns"
139
+
140
+ result = test_class.send(:namespaced_key, "mykey", nil)
141
+
142
+ expect(result).to eq("thread_ns:mykey")
143
+ ensure
144
+ Thread.current[:mudis_namespace] = nil
145
+ end
146
+
147
+ it "prefers explicit namespace over thread-local" do
148
+ Thread.current[:mudis_namespace] = "thread_ns"
149
+
150
+ result = test_class.send(:namespaced_key, "mykey", "explicit_ns")
151
+
152
+ expect(result).to eq("explicit_ns:mykey")
153
+ ensure
154
+ Thread.current[:mudis_namespace] = nil
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../spec_helper"
4
+
5
+ RSpec.describe Mudis::Persistence do
6
+ let(:test_class) do
7
+ Class.new do
8
+ extend Mudis::Persistence
9
+
10
+ @persistence_enabled = true
11
+ @persistence_path = "tmp/test_persistence.json"
12
+ @persistence_format = :json
13
+ @persistence_safe_write = true
14
+ @buckets = 2
15
+ @mutexes = Array.new(2) { Mutex.new }
16
+ @stores = Array.new(2) { {} }
17
+ @lru_nodes = Array.new(2) { {} }
18
+ @lru_heads = Array.new(2) { nil }
19
+ @lru_tails = Array.new(2) { nil }
20
+ @current_bytes = Array.new(2, 0)
21
+ @compress = false
22
+ @serializer = JSON
23
+
24
+ class << self
25
+ attr_accessor :persistence_enabled, :persistence_path, :persistence_format,
26
+ :persistence_safe_write, :buckets, :mutexes, :stores, :compress, :serializer
27
+
28
+ def decompress_and_deserialize(raw)
29
+ JSON.load(raw)
30
+ end
31
+
32
+ def write(key, value, expires_in: nil)
33
+ # Stub write method
34
+ @stores[0][key] = { value: JSON.dump(value), expires_at: nil, created_at: Time.now }
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ after do
41
+ File.unlink(test_class.persistence_path) if File.exist?(test_class.persistence_path)
42
+ end
43
+
44
+ describe "#save_snapshot!" do
45
+ it "saves cache data to disk" do
46
+ test_class.stores[0]["key1"] = {
47
+ value: JSON.dump("value1"),
48
+ expires_at: nil,
49
+ created_at: Time.now
50
+ }
51
+
52
+ test_class.save_snapshot!
53
+
54
+ expect(File.exist?(test_class.persistence_path)).to be true
55
+ end
56
+
57
+ it "handles errors gracefully" do
58
+ allow(test_class).to receive(:snapshot_dump).and_raise("Test error")
59
+
60
+ expect { test_class.save_snapshot! }.to output(/Failed to save snapshot/).to_stderr
61
+ end
62
+
63
+ it "does nothing when persistence is disabled" do
64
+ test_class.persistence_enabled = false
65
+
66
+ test_class.save_snapshot!
67
+
68
+ expect(File.exist?(test_class.persistence_path)).to be false
69
+ end
70
+ end
71
+
72
+ describe "#load_snapshot!" do
73
+ it "loads cache data from disk" do
74
+ data = [{ key: "test_key", value: "test_value", expires_in: nil }]
75
+ File.write(test_class.persistence_path, JSON.dump(data))
76
+
77
+ expect(test_class).to receive(:write).with("test_key", "test_value", expires_in: nil)
78
+
79
+ test_class.load_snapshot!
80
+ end
81
+
82
+ it "handles missing file gracefully" do
83
+ expect { test_class.load_snapshot! }.not_to raise_error
84
+ end
85
+
86
+ it "handles errors gracefully" do
87
+ File.write(test_class.persistence_path, "invalid json")
88
+
89
+ expect { test_class.load_snapshot! }.to output(/Failed to load snapshot/).to_stderr
90
+ end
91
+
92
+ it "does nothing when persistence is disabled" do
93
+ test_class.persistence_enabled = false
94
+ File.write(test_class.persistence_path, JSON.dump([]))
95
+
96
+ expect(test_class).not_to receive(:write)
97
+
98
+ test_class.load_snapshot!
99
+ end
100
+ end
101
+
102
+ describe "#install_persistence_hook!" do
103
+ it "installs at_exit hook" do
104
+ expect(test_class).to receive(:at_exit)
105
+
106
+ test_class.install_persistence_hook!
107
+ end
108
+
109
+ it "only installs hook once" do
110
+ test_class.install_persistence_hook!
111
+
112
+ expect(test_class).not_to receive(:at_exit)
113
+
114
+ test_class.install_persistence_hook!
115
+ end
116
+
117
+ it "does nothing when persistence is disabled" do
118
+ test_class.persistence_enabled = false
119
+
120
+ expect(test_class).not_to receive(:at_exit)
121
+
122
+ test_class.install_persistence_hook!
123
+ end
124
+ end
125
+ end