mudis 0.8.1 → 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.
- checksums.yaml +4 -4
- data/README.md +11 -5
- data/lib/mudis/expiry.rb +53 -0
- data/lib/mudis/lru.rb +66 -0
- data/lib/mudis/metrics.rb +42 -0
- data/lib/mudis/namespace.rb +47 -0
- data/lib/mudis/persistence.rb +113 -0
- data/lib/mudis/version.rb +1 -1
- data/lib/mudis.rb +15 -285
- data/lib/mudis_client.rb +63 -21
- data/lib/mudis_ipc_config.rb +13 -0
- data/lib/mudis_proxy.rb +2 -0
- data/lib/mudis_server.rb +73 -54
- data/sig/mudis.rbs +6 -0
- data/sig/mudis_client.rbs +3 -1
- data/sig/mudis_expiry.rbs +13 -0
- data/sig/mudis_ipc_config.rbs +10 -0
- data/sig/mudis_lru.rbs +21 -0
- data/sig/mudis_metrics.rbs +11 -0
- data/sig/mudis_namespace.rbs +13 -0
- data/sig/mudis_persistence.rbs +19 -0
- data/sig/mudis_server.rbs +12 -2
- data/spec/api_compatibility_spec.rb +155 -0
- data/spec/modules/expiry_spec.rb +170 -0
- data/spec/modules/lru_spec.rb +149 -0
- data/spec/modules/metrics_spec.rb +105 -0
- data/spec/modules/namespace_spec.rb +157 -0
- data/spec/modules/persistence_spec.rb +125 -0
- data/spec/mudis_client_spec.rb +15 -5
- data/spec/mudis_server_spec.rb +23 -17
- metadata +46 -2
data/sig/mudis_lru.rbs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class Mudis
|
|
2
|
+
class LRUNode
|
|
3
|
+
attr_accessor key: String
|
|
4
|
+
attr_accessor prev: LRUNode?
|
|
5
|
+
attr_accessor next: LRUNode?
|
|
6
|
+
|
|
7
|
+
def initialize: (String) -> void
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module LRU
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def evict_key: (Integer, String) -> void
|
|
14
|
+
|
|
15
|
+
def insert_lru: (Integer, LRUNode) -> void
|
|
16
|
+
|
|
17
|
+
def promote_lru: (Integer, String) -> void
|
|
18
|
+
|
|
19
|
+
def remove_node: (Integer, LRUNode) -> void
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class Mudis
|
|
2
|
+
module Namespace
|
|
3
|
+
def keys: (namespace: String) -> Array[String]
|
|
4
|
+
|
|
5
|
+
def clear_namespace: (namespace: String) -> void
|
|
6
|
+
|
|
7
|
+
def with_namespace: (namespace: String) { () -> untyped } -> untyped
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def namespaced_key: (String, ?namespace: String?) -> String
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class Mudis
|
|
2
|
+
module Persistence
|
|
3
|
+
def save_snapshot!: () -> void
|
|
4
|
+
|
|
5
|
+
def load_snapshot!: () -> void
|
|
6
|
+
|
|
7
|
+
def install_persistence_hook!: () -> void
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def snapshot_dump: () -> Hash[String, untyped]
|
|
12
|
+
|
|
13
|
+
def snapshot_restore: (Hash[String, untyped]) -> void
|
|
14
|
+
|
|
15
|
+
def safe_write_snapshot: (Hash[String, untyped]) -> void
|
|
16
|
+
|
|
17
|
+
def read_snapshot: () -> Hash[String, untyped]
|
|
18
|
+
end
|
|
19
|
+
end
|
data/sig/mudis_server.rbs
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
class MudisServer
|
|
2
|
-
|
|
2
|
+
include MudisIPCConfig
|
|
3
3
|
|
|
4
4
|
def self.start!: () -> void
|
|
5
5
|
|
|
6
|
-
def self.
|
|
6
|
+
def self.start_tcp_server!: () -> void
|
|
7
|
+
|
|
8
|
+
def self.start_unix_server!: () -> void
|
|
9
|
+
|
|
10
|
+
def self.accept_connections: (server: (TCPServer | UNIXServer)) -> void
|
|
11
|
+
|
|
12
|
+
def self.handle_client: (socket: (TCPSocket | UNIXSocket)) -> void
|
|
13
|
+
|
|
14
|
+
def self.process_request: (req: Hash[String, untyped]) -> Hash[Symbol, untyped]
|
|
15
|
+
|
|
16
|
+
def self.write_response: (socket: (TCPSocket | UNIXSocket), payload: Hash[Symbol, untyped]) -> void
|
|
7
17
|
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Mudis Public API" do
|
|
6
|
+
describe "ensures no breaking changes" do
|
|
7
|
+
it "exposes all core cache operations" do
|
|
8
|
+
expect(Mudis).to respond_to(:read)
|
|
9
|
+
expect(Mudis).to respond_to(:write)
|
|
10
|
+
expect(Mudis).to respond_to(:delete)
|
|
11
|
+
expect(Mudis).to respond_to(:exists?)
|
|
12
|
+
expect(Mudis).to respond_to(:update)
|
|
13
|
+
expect(Mudis).to respond_to(:fetch)
|
|
14
|
+
expect(Mudis).to respond_to(:clear)
|
|
15
|
+
expect(Mudis).to respond_to(:replace)
|
|
16
|
+
expect(Mudis).to respond_to(:inspect)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "exposes all configuration methods" do
|
|
20
|
+
expect(Mudis).to respond_to(:configure)
|
|
21
|
+
expect(Mudis).to respond_to(:config)
|
|
22
|
+
expect(Mudis).to respond_to(:serializer)
|
|
23
|
+
expect(Mudis).to respond_to(:serializer=)
|
|
24
|
+
expect(Mudis).to respond_to(:compress)
|
|
25
|
+
expect(Mudis).to respond_to(:compress=)
|
|
26
|
+
expect(Mudis).to respond_to(:max_bytes)
|
|
27
|
+
expect(Mudis).to respond_to(:max_bytes=)
|
|
28
|
+
expect(Mudis).to respond_to(:max_value_bytes)
|
|
29
|
+
expect(Mudis).to respond_to(:max_value_bytes=)
|
|
30
|
+
expect(Mudis).to respond_to(:hard_memory_limit)
|
|
31
|
+
expect(Mudis).to respond_to(:hard_memory_limit=)
|
|
32
|
+
expect(Mudis).to respond_to(:max_ttl)
|
|
33
|
+
expect(Mudis).to respond_to(:max_ttl=)
|
|
34
|
+
expect(Mudis).to respond_to(:default_ttl)
|
|
35
|
+
expect(Mudis).to respond_to(:default_ttl=)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "exposes all metrics methods" do
|
|
39
|
+
expect(Mudis).to respond_to(:metrics)
|
|
40
|
+
expect(Mudis).to respond_to(:reset_metrics!)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "exposes all expiry methods" do
|
|
44
|
+
expect(Mudis).to respond_to(:start_expiry_thread)
|
|
45
|
+
expect(Mudis).to respond_to(:stop_expiry_thread)
|
|
46
|
+
expect(Mudis).to respond_to(:cleanup_expired!)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "exposes all namespace methods" do
|
|
50
|
+
expect(Mudis).to respond_to(:keys)
|
|
51
|
+
expect(Mudis).to respond_to(:clear_namespace)
|
|
52
|
+
expect(Mudis).to respond_to(:with_namespace)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "exposes all persistence methods" do
|
|
56
|
+
expect(Mudis).to respond_to(:save_snapshot!)
|
|
57
|
+
expect(Mudis).to respond_to(:load_snapshot!)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "exposes all utility methods" do
|
|
61
|
+
expect(Mudis).to respond_to(:reset!)
|
|
62
|
+
expect(Mudis).to respond_to(:all_keys)
|
|
63
|
+
expect(Mudis).to respond_to(:current_memory_bytes)
|
|
64
|
+
expect(Mudis).to respond_to(:max_memory_bytes)
|
|
65
|
+
expect(Mudis).to respond_to(:least_touched)
|
|
66
|
+
expect(Mudis).to respond_to(:buckets)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "maintains backward compatibility with method signatures" do
|
|
70
|
+
# Core operations accept namespace parameter
|
|
71
|
+
expect(Mudis.method(:read).parameters).to include([:key, :namespace])
|
|
72
|
+
expect(Mudis.method(:write).parameters).to include([:key, :namespace])
|
|
73
|
+
expect(Mudis.method(:delete).parameters).to include([:key, :namespace])
|
|
74
|
+
expect(Mudis.method(:exists?).parameters).to include([:key, :namespace])
|
|
75
|
+
|
|
76
|
+
# Fetch accepts expires_in, force, and namespace
|
|
77
|
+
expect(Mudis.method(:fetch).parameters).to include([:key, :expires_in])
|
|
78
|
+
expect(Mudis.method(:fetch).parameters).to include([:key, :force])
|
|
79
|
+
expect(Mudis.method(:fetch).parameters).to include([:key, :namespace])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "verifies LRUNode class is still accessible" do
|
|
83
|
+
expect(defined?(Mudis::LRUNode)).to be_truthy
|
|
84
|
+
node = Mudis::LRUNode.new("test_key")
|
|
85
|
+
expect(node).to respond_to(:key)
|
|
86
|
+
expect(node).to respond_to(:prev)
|
|
87
|
+
expect(node).to respond_to(:next)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "verifies all public methods work correctly" do
|
|
91
|
+
Mudis.reset!
|
|
92
|
+
|
|
93
|
+
# Write and read
|
|
94
|
+
Mudis.write("test", "value")
|
|
95
|
+
expect(Mudis.read("test")).to eq("value")
|
|
96
|
+
|
|
97
|
+
# Exists
|
|
98
|
+
expect(Mudis.exists?("test")).to be true
|
|
99
|
+
|
|
100
|
+
# Update
|
|
101
|
+
Mudis.update("test") { |v| v.upcase }
|
|
102
|
+
expect(Mudis.read("test")).to eq("VALUE")
|
|
103
|
+
|
|
104
|
+
# Fetch
|
|
105
|
+
result = Mudis.fetch("new_key") { "new_value" }
|
|
106
|
+
expect(result).to eq("new_value")
|
|
107
|
+
|
|
108
|
+
# Replace
|
|
109
|
+
Mudis.replace("test", "replaced")
|
|
110
|
+
expect(Mudis.read("test")).to eq("replaced")
|
|
111
|
+
|
|
112
|
+
# Inspect
|
|
113
|
+
meta = Mudis.inspect("test")
|
|
114
|
+
expect(meta).to include(:key, :bucket, :created_at, :size_bytes)
|
|
115
|
+
|
|
116
|
+
# Namespace operations
|
|
117
|
+
Mudis.write("k1", "v1", namespace: "ns1")
|
|
118
|
+
expect(Mudis.keys(namespace: "ns1")).to include("k1")
|
|
119
|
+
|
|
120
|
+
Mudis.with_namespace("ns2") do
|
|
121
|
+
Mudis.write("k2", "v2")
|
|
122
|
+
end
|
|
123
|
+
expect(Mudis.keys(namespace: "ns2")).to include("k2")
|
|
124
|
+
|
|
125
|
+
# Clear namespace
|
|
126
|
+
Mudis.clear_namespace(namespace: "ns1")
|
|
127
|
+
expect(Mudis.keys(namespace: "ns1")).to be_empty
|
|
128
|
+
|
|
129
|
+
# Metrics
|
|
130
|
+
metrics = Mudis.metrics
|
|
131
|
+
expect(metrics).to include(:hits, :misses, :evictions, :total_memory, :buckets)
|
|
132
|
+
|
|
133
|
+
# Least touched
|
|
134
|
+
touched = Mudis.least_touched(5)
|
|
135
|
+
expect(touched).to be_an(Array)
|
|
136
|
+
|
|
137
|
+
# All keys
|
|
138
|
+
keys = Mudis.all_keys
|
|
139
|
+
expect(keys).to be_an(Array)
|
|
140
|
+
|
|
141
|
+
# Memory tracking
|
|
142
|
+
expect(Mudis.current_memory_bytes).to be > 0
|
|
143
|
+
expect(Mudis.max_memory_bytes).to be > 0
|
|
144
|
+
|
|
145
|
+
# Delete
|
|
146
|
+
Mudis.delete("test")
|
|
147
|
+
expect(Mudis.exists?("test")).to be false
|
|
148
|
+
|
|
149
|
+
# Clear (alias for delete)
|
|
150
|
+
Mudis.write("to_clear", "value")
|
|
151
|
+
Mudis.clear("to_clear")
|
|
152
|
+
expect(Mudis.exists?("to_clear")).to be false
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Mudis::Expiry do
|
|
6
|
+
let(:test_class) do
|
|
7
|
+
Class.new do
|
|
8
|
+
extend Mudis::Expiry
|
|
9
|
+
|
|
10
|
+
@expiry_thread = nil
|
|
11
|
+
@stop_expiry = false
|
|
12
|
+
@buckets = 2
|
|
13
|
+
@mutexes = Array.new(2) { Mutex.new }
|
|
14
|
+
@stores = Array.new(2) { {} }
|
|
15
|
+
@max_ttl = nil
|
|
16
|
+
@default_ttl = nil
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
attr_accessor :expiry_thread, :stop_expiry, :buckets, :mutexes, :stores, :max_ttl, :default_ttl
|
|
20
|
+
|
|
21
|
+
def evict_key(idx, key)
|
|
22
|
+
@stores[idx].delete(key)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
after do
|
|
29
|
+
test_class.stop_expiry_thread if test_class.expiry_thread&.alive?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe "#start_expiry_thread" do
|
|
33
|
+
it "starts a background cleanup thread" do
|
|
34
|
+
test_class.start_expiry_thread(interval: 0.1)
|
|
35
|
+
|
|
36
|
+
expect(test_class.expiry_thread).to be_alive
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "does not start duplicate thread if already running" do
|
|
40
|
+
test_class.start_expiry_thread(interval: 0.1)
|
|
41
|
+
first_thread = test_class.expiry_thread
|
|
42
|
+
|
|
43
|
+
test_class.start_expiry_thread(interval: 0.1)
|
|
44
|
+
|
|
45
|
+
expect(test_class.expiry_thread).to eq(first_thread)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "periodically calls cleanup_expired!" do
|
|
49
|
+
allow(test_class).to receive(:cleanup_expired!)
|
|
50
|
+
|
|
51
|
+
test_class.start_expiry_thread(interval: 0.05)
|
|
52
|
+
sleep 0.15
|
|
53
|
+
|
|
54
|
+
expect(test_class).to have_received(:cleanup_expired!).at_least(:once)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe "#stop_expiry_thread" do
|
|
59
|
+
it "stops the background thread" do
|
|
60
|
+
test_class.start_expiry_thread(interval: 0.1)
|
|
61
|
+
|
|
62
|
+
test_class.stop_expiry_thread
|
|
63
|
+
|
|
64
|
+
expect(test_class.expiry_thread).to be_nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "sets stop signal" do
|
|
68
|
+
test_class.start_expiry_thread(interval: 0.1)
|
|
69
|
+
|
|
70
|
+
test_class.stop_expiry_thread
|
|
71
|
+
|
|
72
|
+
expect(test_class.stop_expiry).to be true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "does nothing if thread is not running" do
|
|
76
|
+
expect { test_class.stop_expiry_thread }.not_to raise_error
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe "#cleanup_expired!" do
|
|
81
|
+
it "removes expired keys from all buckets" do
|
|
82
|
+
now = Time.now
|
|
83
|
+
test_class.stores[0]["expired"] = { expires_at: now - 10 }
|
|
84
|
+
test_class.stores[0]["valid"] = { expires_at: now + 10 }
|
|
85
|
+
test_class.stores[1]["also_expired"] = { expires_at: now - 5 }
|
|
86
|
+
|
|
87
|
+
test_class.cleanup_expired!
|
|
88
|
+
|
|
89
|
+
expect(test_class.stores[0]).not_to have_key("expired")
|
|
90
|
+
expect(test_class.stores[0]).to have_key("valid")
|
|
91
|
+
expect(test_class.stores[1]).not_to have_key("also_expired")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "keeps keys without expiration" do
|
|
95
|
+
test_class.stores[0]["no_expiry"] = { expires_at: nil }
|
|
96
|
+
|
|
97
|
+
test_class.cleanup_expired!
|
|
98
|
+
|
|
99
|
+
expect(test_class.stores[0]).to have_key("no_expiry")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "is thread-safe" do
|
|
103
|
+
10.times do |i|
|
|
104
|
+
test_class.stores[0]["key#{i}"] = { expires_at: Time.now - 1 }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
threads = 3.times.map do
|
|
108
|
+
Thread.new { test_class.cleanup_expired! }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
expect { threads.each(&:join) }.not_to raise_error
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe "#effective_ttl (private)" do
|
|
116
|
+
it "returns provided expires_in when no constraints" do
|
|
117
|
+
result = test_class.send(:effective_ttl, 300)
|
|
118
|
+
|
|
119
|
+
expect(result).to eq(300)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "returns nil when expires_in is nil and no default" do
|
|
123
|
+
result = test_class.send(:effective_ttl, nil)
|
|
124
|
+
|
|
125
|
+
expect(result).to be_nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "uses default_ttl when expires_in is nil" do
|
|
129
|
+
test_class.default_ttl = 600
|
|
130
|
+
|
|
131
|
+
result = test_class.send(:effective_ttl, nil)
|
|
132
|
+
|
|
133
|
+
expect(result).to eq(600)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "clamps to max_ttl when expires_in exceeds it" do
|
|
137
|
+
test_class.max_ttl = 100
|
|
138
|
+
|
|
139
|
+
result = test_class.send(:effective_ttl, 500)
|
|
140
|
+
|
|
141
|
+
expect(result).to eq(100)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "allows expires_in below max_ttl" do
|
|
145
|
+
test_class.max_ttl = 1000
|
|
146
|
+
|
|
147
|
+
result = test_class.send(:effective_ttl, 500)
|
|
148
|
+
|
|
149
|
+
expect(result).to eq(500)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "applies max_ttl over default_ttl" do
|
|
153
|
+
test_class.max_ttl = 100
|
|
154
|
+
test_class.default_ttl = 500
|
|
155
|
+
|
|
156
|
+
result = test_class.send(:effective_ttl, nil)
|
|
157
|
+
|
|
158
|
+
expect(result).to eq(100)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "returns nil when both expires_in and default_ttl are nil" do
|
|
162
|
+
test_class.max_ttl = 1000
|
|
163
|
+
test_class.default_ttl = nil
|
|
164
|
+
|
|
165
|
+
result = test_class.send(:effective_ttl, nil)
|
|
166
|
+
|
|
167
|
+
expect(result).to be_nil
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -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
|