mudis 0.9.0 → 0.9.4

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/sig/mudis_server.rbs CHANGED
@@ -7,11 +7,11 @@ class MudisServer
7
7
 
8
8
  def self.start_unix_server!: () -> void
9
9
 
10
- def self.accept_connections: (server: (TCPServer | UNIXServer)) -> void
10
+ def self.accept_connections: (server: (TCPServer | UNIXServer)) -> Thread
11
11
 
12
12
  def self.handle_client: (socket: (TCPSocket | UNIXSocket)) -> void
13
13
 
14
- def self.process_request: (req: Hash[String, untyped]) -> Hash[Symbol, untyped]
14
+ def self.process_request: (req: Hash[Symbol, untyped]) -> untyped
15
15
 
16
16
  def self.write_response: (socket: (TCPSocket | UNIXSocket), payload: Hash[Symbol, untyped]) -> void
17
- end
17
+ end
@@ -29,6 +29,8 @@ RSpec.describe "Mudis Public API" do
29
29
  expect(Mudis).to respond_to(:max_value_bytes=)
30
30
  expect(Mudis).to respond_to(:hard_memory_limit)
31
31
  expect(Mudis).to respond_to(:hard_memory_limit=)
32
+ expect(Mudis).to respond_to(:eviction_threshold)
33
+ expect(Mudis).to respond_to(:eviction_threshold=)
32
34
  expect(Mudis).to respond_to(:max_ttl)
33
35
  expect(Mudis).to respond_to(:max_ttl=)
34
36
  expect(Mudis).to respond_to(:default_ttl)
@@ -77,6 +79,7 @@ RSpec.describe "Mudis Public API" do
77
79
  expect(Mudis.method(:fetch).parameters).to include([:key, :expires_in])
78
80
  expect(Mudis.method(:fetch).parameters).to include([:key, :force])
79
81
  expect(Mudis.method(:fetch).parameters).to include([:key, :namespace])
82
+ expect(Mudis.method(:fetch).parameters).to include([:key, :singleflight])
80
83
  end
81
84
 
82
85
  it "verifies LRUNode class is still accessible" do
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spec_helper"
4
+
5
+ RSpec.describe Mudis::Bound do
6
+ before do
7
+ Mudis.reset!
8
+ Mudis.serializer = JSON
9
+ Mudis.compress = false
10
+ end
11
+
12
+ it "scopes reads and writes to the bound namespace" do
13
+ bound = Mudis.bind(namespace: "caller")
14
+ bound.write("k", "v")
15
+
16
+ expect(Mudis.read("k")).to be_nil
17
+ expect(bound.read("k")).to eq("v")
18
+ expect(Mudis.read("k", namespace: "caller")).to eq("v")
19
+ end
20
+
21
+ it "applies default_ttl and max_ttl within the scope" do
22
+ bound = Mudis.bind(namespace: "caller", default_ttl: 120, max_ttl: 30)
23
+ bound.write("k", "v")
24
+
25
+ meta = bound.inspect("k")
26
+ expect(meta[:expires_at]).not_to be_nil
27
+ expect(meta[:expires_at]).to be_within(5).of(Time.now + 30)
28
+ end
29
+
30
+ it "rejects values that exceed max_value_bytes" do
31
+ bound = Mudis.bind(namespace: "caller", max_value_bytes: 10)
32
+ bound.write("k", "a" * 20)
33
+
34
+ expect(bound.read("k")).to be_nil
35
+ end
36
+
37
+ it "rejects updates that exceed max_value_bytes" do
38
+ bound = Mudis.bind(namespace: "caller", max_value_bytes: 10)
39
+ bound.write("k", "ok")
40
+
41
+ bound.update("k") { "a" * 20 }
42
+ expect(bound.read("k")).to eq("ok")
43
+ end
44
+
45
+ it "fetches within the bound namespace" do
46
+ bound = Mudis.bind(namespace: "caller")
47
+ value = bound.fetch("k") { "v" }
48
+
49
+ expect(value).to eq("v")
50
+ expect(Mudis.read("k")).to be_nil
51
+ expect(bound.read("k")).to eq("v")
52
+ end
53
+
54
+ it "executes the block once with singleflight: true" do
55
+ bound = Mudis.bind(namespace: "caller")
56
+ count = 0
57
+ count_mutex = Mutex.new
58
+ results = []
59
+ results_mutex = Mutex.new
60
+
61
+ threads = 5.times.map do
62
+ Thread.new do
63
+ value = bound.fetch("sf", singleflight: true) do
64
+ count_mutex.synchronize { count += 1 }
65
+ sleep 0.05
66
+ "v"
67
+ end
68
+ results_mutex.synchronize { results << value }
69
+ end
70
+ end
71
+
72
+ threads.each(&:join)
73
+ expect(count).to eq(1)
74
+ expect(results).to all(eq("v"))
75
+ expect(bound.read("sf")).to eq("v")
76
+ end
77
+
78
+ it "exposes metrics scoped to the bound namespace" do
79
+ bound = Mudis.bind(namespace: "caller")
80
+ bound.write("k", "v")
81
+ bound.read("k")
82
+ bound.read("missing")
83
+
84
+ metrics = bound.metrics
85
+ expect(metrics[:namespace]).to eq("caller")
86
+ expect(metrics[:hits]).to eq(1)
87
+ expect(metrics[:misses]).to eq(1)
88
+ end
89
+ end
@@ -134,5 +134,19 @@ RSpec.describe "Mudis Configuration Guardrails" do # rubocop:disable Metrics/Blo
134
134
  end
135
135
  end.to raise_error(ArgumentError, /max_value_bytes cannot exceed max_bytes/)
136
136
  end
137
+
138
+ it "raises if eviction_threshold is <= 0 or > 1" do
139
+ expect do
140
+ Mudis.configure do |c|
141
+ c.eviction_threshold = 0
142
+ end
143
+ end.to raise_error(ArgumentError, /eviction_threshold must be > 0 and <= 1/)
144
+
145
+ expect do
146
+ Mudis.configure do |c|
147
+ c.eviction_threshold = 1.5
148
+ end
149
+ end.to raise_error(ArgumentError, /eviction_threshold must be > 0 and <= 1/)
150
+ end
137
151
  end
138
152
  end
@@ -30,4 +30,13 @@ RSpec.describe "Mudis Memory Guardrails" do
30
30
  expect(Mudis.read("b")).to be_nil
31
31
  expect(Mudis.metrics[:rejected]).to be > 0
32
32
  end
33
+
34
+ it "rejects updates that exceed max memory" do
35
+ Mudis.write("a", "a" * 10)
36
+ expect(Mudis.read("a")).to eq("a" * 10)
37
+
38
+ Mudis.update("a") { "b" * 200 }
39
+ expect(Mudis.read("a")).to eq("a" * 10)
40
+ expect(Mudis.metrics[:rejected]).to be > 0
41
+ end
33
42
  end
data/spec/metrics_spec.rb CHANGED
@@ -31,4 +31,25 @@ RSpec.describe "Mudis Metrics" do # rubocop:disable Metrics/BlockLength
31
31
  expect(Mudis.metrics[:misses]).to eq(0)
32
32
  expect(Mudis.read("metrics_key")).to eq("value")
33
33
  end
34
+
35
+ it "tracks metrics per namespace" do
36
+ Mudis.write("k1", "v1", namespace: "ns1")
37
+ Mudis.write("k2", "v2", namespace: "ns2")
38
+
39
+ Mudis.read("k1", namespace: "ns1")
40
+ Mudis.read("k1", namespace: "ns1")
41
+ Mudis.read("missing", namespace: "ns1")
42
+ Mudis.read("k2", namespace: "ns2")
43
+
44
+ ns1 = Mudis.metrics(namespace: "ns1")
45
+ ns2 = Mudis.metrics(namespace: "ns2")
46
+
47
+ expect(ns1[:hits]).to eq(2)
48
+ expect(ns1[:misses]).to eq(1)
49
+ expect(ns1[:namespace]).to eq("ns1")
50
+
51
+ expect(ns2[:hits]).to eq(1)
52
+ expect(ns2[:misses]).to eq(0)
53
+ expect(ns2[:namespace]).to eq("ns2")
54
+ end
34
55
  end
@@ -9,13 +9,16 @@ RSpec.describe Mudis::Metrics do
9
9
 
10
10
  @metrics = { hits: 5, misses: 3, evictions: 2, rejected: 1 }
11
11
  @metrics_mutex = Mutex.new
12
+ @metrics_by_namespace = {}
13
+ @metrics_by_namespace_mutex = Mutex.new
12
14
  @buckets = 2
13
15
  @stores = [{ "key1" => {} }, { "key2" => {} }]
14
16
  @current_bytes = [100, 200]
15
17
  @lru_nodes = [{ "key1" => nil }, { "key2" => nil }]
16
18
 
17
19
  class << self
18
- attr_accessor :metrics, :metrics_mutex, :buckets, :stores, :current_bytes, :lru_nodes
20
+ attr_accessor :metrics, :metrics_mutex, :metrics_by_namespace, :metrics_by_namespace_mutex,
21
+ :buckets, :stores, :current_bytes, :lru_nodes
19
22
 
20
23
  def current_memory_bytes
21
24
  @current_bytes.sum
@@ -2,11 +2,10 @@
2
2
 
3
3
  require_relative "../spec_helper"
4
4
 
5
- RSpec.describe Mudis::Persistence do
6
- let(:test_class) do
5
+ RSpec.describe Mudis::Persistence do # rubocop:disable Metrics/BlockLength
6
+ let(:test_class) do # rubocop:disable Metrics/BlockLength
7
7
  Class.new do
8
8
  extend Mudis::Persistence
9
-
10
9
  @persistence_enabled = true
11
10
  @persistence_path = "tmp/test_persistence.json"
12
11
  @persistence_format = :json
@@ -20,15 +19,14 @@ RSpec.describe Mudis::Persistence do
20
19
  @current_bytes = Array.new(2, 0)
21
20
  @compress = false
22
21
  @serializer = JSON
23
-
24
22
  class << self
25
- attr_accessor :persistence_enabled, :persistence_path, :persistence_format,
23
+ attr_accessor :persistence_enabled, :persistence_path, :persistence_format,
26
24
  :persistence_safe_write, :buckets, :mutexes, :stores, :compress, :serializer
27
-
25
+
28
26
  def decompress_and_deserialize(raw)
29
27
  JSON.load(raw)
30
28
  end
31
-
29
+
32
30
  def write(key, value, expires_in: nil)
33
31
  # Stub write method
34
32
  @stores[0][key] = { value: JSON.dump(value), expires_at: nil, created_at: Time.now }
@@ -43,28 +41,28 @@ RSpec.describe Mudis::Persistence do
43
41
 
44
42
  describe "#save_snapshot!" do
45
43
  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
44
+ test_class.stores[0]["key1"] = {
45
+ value: JSON.dump("value1"),
46
+ expires_at: nil,
47
+ created_at: Time.now
50
48
  }
51
-
49
+
52
50
  test_class.save_snapshot!
53
-
51
+
54
52
  expect(File.exist?(test_class.persistence_path)).to be true
55
53
  end
56
54
 
57
55
  it "handles errors gracefully" do
58
56
  allow(test_class).to receive(:snapshot_dump).and_raise("Test error")
59
-
57
+
60
58
  expect { test_class.save_snapshot! }.to output(/Failed to save snapshot/).to_stderr
61
59
  end
62
60
 
63
61
  it "does nothing when persistence is disabled" do
64
62
  test_class.persistence_enabled = false
65
-
63
+
66
64
  test_class.save_snapshot!
67
-
65
+
68
66
  expect(File.exist?(test_class.persistence_path)).to be false
69
67
  end
70
68
  end
@@ -73,9 +71,9 @@ RSpec.describe Mudis::Persistence do
73
71
  it "loads cache data from disk" do
74
72
  data = [{ key: "test_key", value: "test_value", expires_in: nil }]
75
73
  File.write(test_class.persistence_path, JSON.dump(data))
76
-
74
+
77
75
  expect(test_class).to receive(:write).with("test_key", "test_value", expires_in: nil)
78
-
76
+
79
77
  test_class.load_snapshot!
80
78
  end
81
79
 
@@ -85,16 +83,16 @@ RSpec.describe Mudis::Persistence do
85
83
 
86
84
  it "handles errors gracefully" do
87
85
  File.write(test_class.persistence_path, "invalid json")
88
-
86
+
89
87
  expect { test_class.load_snapshot! }.to output(/Failed to load snapshot/).to_stderr
90
88
  end
91
89
 
92
90
  it "does nothing when persistence is disabled" do
93
91
  test_class.persistence_enabled = false
94
92
  File.write(test_class.persistence_path, JSON.dump([]))
95
-
93
+
96
94
  expect(test_class).not_to receive(:write)
97
-
95
+
98
96
  test_class.load_snapshot!
99
97
  end
100
98
  end
@@ -102,23 +100,23 @@ RSpec.describe Mudis::Persistence do
102
100
  describe "#install_persistence_hook!" do
103
101
  it "installs at_exit hook" do
104
102
  expect(test_class).to receive(:at_exit)
105
-
103
+
106
104
  test_class.install_persistence_hook!
107
105
  end
108
106
 
109
107
  it "only installs hook once" do
110
108
  test_class.install_persistence_hook!
111
-
109
+
112
110
  expect(test_class).not_to receive(:at_exit)
113
-
111
+
114
112
  test_class.install_persistence_hook!
115
113
  end
116
114
 
117
115
  it "does nothing when persistence is disabled" do
118
116
  test_class.persistence_enabled = false
119
-
117
+
120
118
  expect(test_class).not_to receive(:at_exit)
121
-
119
+
122
120
  test_class.install_persistence_hook!
123
121
  end
124
122
  end
@@ -18,7 +18,7 @@ RSpec.describe MudisClient do # rubocop:disable Metrics/BlockLength
18
18
  if MudisIPCConfig.use_tcp?
19
19
  allow(TCPSocket).to receive(:new).and_return(mock_socket)
20
20
  else
21
- allow(UNIXSocket).to receive(:open).and_yield(mock_socket)
21
+ allow(UNIXSocket).to receive(:open).and_return(mock_socket)
22
22
  end
23
23
  allow(mock_socket).to receive(:close)
24
24
  end
@@ -99,32 +99,93 @@ RSpec.describe MudisClient do # rubocop:disable Metrics/BlockLength
99
99
  end
100
100
  end
101
101
 
102
- describe "#reset_metrics!" do
103
- it "sends a reset_metrics command" do
104
- payload = { cmd: "reset_metrics" }
105
- response = { ok: true, value: nil }.to_json
102
+ describe "#inspect" do
103
+ it "sends an inspect command and returns metadata" do
104
+ payload = { cmd: "inspect", key: "test_key", namespace: nil }
105
+ response = { ok: true, value: { key: "test_key", size_bytes: 10 } }.to_json
106
106
 
107
107
  expect(mock_socket).to receive(:puts).with(payload.to_json)
108
108
  expect(mock_socket).to receive(:gets).and_return(response)
109
109
 
110
- expect(client.reset_metrics!).to be_nil
110
+ expect(client.inspect("test_key")).to eq({ key: "test_key", size_bytes: 10 })
111
111
  end
112
112
  end
113
113
 
114
- describe "#reset!" do
115
- it "sends a reset command" do
116
- payload = { cmd: "reset" }
114
+ describe "#keys" do
115
+ it "sends a keys command and returns keys" do
116
+ payload = { cmd: "keys", namespace: "ns" }
117
+ response = { ok: true, value: ["a", "b"] }.to_json
118
+
119
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
120
+ expect(mock_socket).to receive(:gets).and_return(response)
121
+
122
+ expect(client.keys(namespace: "ns")).to eq(["a", "b"])
123
+ end
124
+ end
125
+
126
+ describe "#clear_namespace" do
127
+ it "sends a clear_namespace command" do
128
+ payload = { cmd: "clear_namespace", namespace: "ns" }
117
129
  response = { ok: true, value: nil }.to_json
118
130
 
119
131
  expect(mock_socket).to receive(:puts).with(payload.to_json)
120
132
  expect(mock_socket).to receive(:gets).and_return(response)
121
133
 
122
- expect(client.reset!).to be_nil
134
+ expect(client.clear_namespace(namespace: "ns")).to be_nil
135
+ end
136
+ end
137
+
138
+ describe "#least_touched" do
139
+ it "sends a least_touched command" do
140
+ payload = { cmd: "least_touched", limit: 5 }
141
+ response = { ok: true, value: [["a", 0], ["b", 1]] }.to_json
142
+
143
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
144
+ expect(mock_socket).to receive(:gets).and_return(response)
145
+
146
+ expect(client.least_touched(5)).to eq([["a", 0], ["b", 1]])
147
+ end
148
+ end
149
+
150
+ describe "#all_keys" do
151
+ it "sends an all_keys command" do
152
+ payload = { cmd: "all_keys" }
153
+ response = { ok: true, value: ["k1"] }.to_json
154
+
155
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
156
+ expect(mock_socket).to receive(:gets).and_return(response)
157
+
158
+ expect(client.all_keys).to eq(["k1"])
159
+ end
160
+ end
161
+
162
+ describe "#current_memory_bytes" do
163
+ it "sends a current_memory_bytes command" do
164
+ payload = { cmd: "current_memory_bytes" }
165
+ response = { ok: true, value: 123 }.to_json
166
+
167
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
168
+ expect(mock_socket).to receive(:gets).and_return(response)
169
+
170
+ expect(client.current_memory_bytes).to eq(123)
171
+ end
172
+ end
173
+
174
+ describe "#max_memory_bytes" do
175
+ it "sends a max_memory_bytes command" do
176
+ payload = { cmd: "max_memory_bytes" }
177
+ response = { ok: true, value: 456 }.to_json
178
+
179
+ expect(mock_socket).to receive(:puts).with(payload.to_json)
180
+ expect(mock_socket).to receive(:gets).and_return(response)
181
+
182
+ expect(client.max_memory_bytes).to eq(456)
123
183
  end
124
184
  end
125
185
 
126
186
  describe "error handling" do
127
187
  it "warns when the socket is missing" do
188
+ allow(MudisIPCConfig).to receive(:retries).and_return(1)
128
189
  if MudisIPCConfig.use_tcp?
129
190
  allow(TCPSocket).to receive(:new).and_raise(Errno::ECONNREFUSED)
130
191
  else
@@ -143,5 +204,18 @@ RSpec.describe MudisClient do # rubocop:disable Metrics/BlockLength
143
204
 
144
205
  expect { client.read("test_key") }.to raise_error("Something went wrong")
145
206
  end
207
+
208
+ it "retries on timeout and then warns" do
209
+ allow(MudisIPCConfig).to receive(:retries).and_return(1)
210
+ allow(MudisIPCConfig).to receive(:timeout).and_return(0.01)
211
+
212
+ if MudisIPCConfig.use_tcp?
213
+ allow(TCPSocket).to receive(:new).and_raise(Timeout::Error)
214
+ else
215
+ allow(UNIXSocket).to receive(:open).and_raise(Timeout::Error)
216
+ end
217
+
218
+ expect { client.read("test_key") }.to output(/Cannot connect/).to_stderr
219
+ end
146
220
  end
147
221
  end
@@ -6,7 +6,12 @@ require "json"
6
6
  require_relative "spec_helper"
7
7
 
8
8
  RSpec.describe MudisServer do # rubocop:disable Metrics/BlockLength
9
- let(:socket_path) { MudisIPCConfig::SOCKET_PATH }
9
+ unless ENV["MUDIS_RUN_IPC"] == "true"
10
+ it "skips IPC socket tests unless MUDIS_RUN_IPC=true" do
11
+ skip "Set MUDIS_RUN_IPC=true to run IPC socket tests"
12
+ end
13
+ next
14
+ end
10
15
 
11
16
  before(:all) do
12
17
  # Start the server once for all tests
@@ -20,12 +25,18 @@ RSpec.describe MudisServer do # rubocop:disable Metrics/BlockLength
20
25
  allow(Mudis).to receive(:delete)
21
26
  allow(Mudis).to receive(:exists?).and_return(true)
22
27
  allow(Mudis).to receive(:fetch).and_return("mock_fetched_value")
28
+ allow(Mudis).to receive(:inspect).and_return({ key: "test_key", size_bytes: 10 })
29
+ allow(Mudis).to receive(:keys).and_return(["a", "b"])
30
+ allow(Mudis).to receive(:clear_namespace)
31
+ allow(Mudis).to receive(:least_touched).and_return([["a", 0]])
32
+ allow(Mudis).to receive(:all_keys).and_return(["k1"])
33
+ allow(Mudis).to receive(:current_memory_bytes).and_return(123)
34
+ allow(Mudis).to receive(:max_memory_bytes).and_return(456)
23
35
  allow(Mudis).to receive(:metrics).and_return({ reads: 1, writes: 1 })
24
- allow(Mudis).to receive(:reset_metrics!)
25
- allow(Mudis).to receive(:reset!)
26
36
  end
27
37
 
28
- after do
38
+ after(:all) do
39
+ socket_path = MudisIPCConfig::SOCKET_PATH
29
40
  File.unlink(socket_path) if File.exist?(socket_path) && !MudisIPCConfig.use_tcp?
30
41
  end
31
42
 
@@ -36,7 +47,7 @@ RSpec.describe MudisServer do # rubocop:disable Metrics/BlockLength
36
47
  JSON.parse(sock.gets, symbolize_names: true)
37
48
  end
38
49
  else
39
- UNIXSocket.open(socket_path) do |sock|
50
+ UNIXSocket.open(MudisIPCConfig::SOCKET_PATH) do |sock|
40
51
  sock.puts(JSON.dump(request))
41
52
  JSON.parse(sock.gets, symbolize_names: true)
42
53
  end
@@ -74,22 +85,52 @@ RSpec.describe MudisServer do # rubocop:disable Metrics/BlockLength
74
85
  expect(Mudis).to have_received(:fetch).with("test_key", expires_in: 60, namespace: "test_ns")
75
86
  end
76
87
 
77
- it "handles the 'metrics' command" do
78
- response = send_request({ cmd: "metrics" })
79
- expect(response).to eq({ ok: true, value: { reads: 1, writes: 1 } })
80
- expect(Mudis).to have_received(:metrics)
88
+ it "handles the 'inspect' command" do
89
+ response = send_request({ cmd: "inspect", key: "test_key", namespace: "test_ns" })
90
+ expect(response).to eq({ ok: true, value: { key: "test_key", size_bytes: 10 } })
91
+ expect(Mudis).to have_received(:inspect).with("test_key", namespace: "test_ns")
81
92
  end
82
93
 
83
- it "handles the 'reset_metrics' command" do
84
- response = send_request({ cmd: "reset_metrics" })
85
- expect(response).to eq({ ok: true, value: nil })
86
- expect(Mudis).to have_received(:reset_metrics!)
94
+ it "handles the 'keys' command" do
95
+ response = send_request({ cmd: "keys", namespace: "test_ns" })
96
+ expect(response).to eq({ ok: true, value: ["a", "b"] })
97
+ expect(Mudis).to have_received(:keys).with(namespace: "test_ns")
87
98
  end
88
99
 
89
- it "handles the 'reset' command" do
90
- response = send_request({ cmd: "reset" })
100
+ it "handles the 'clear_namespace' command" do
101
+ response = send_request({ cmd: "clear_namespace", namespace: "test_ns" })
91
102
  expect(response).to eq({ ok: true, value: nil })
92
- expect(Mudis).to have_received(:reset!)
103
+ expect(Mudis).to have_received(:clear_namespace).with(namespace: "test_ns")
104
+ end
105
+
106
+ it "handles the 'least_touched' command" do
107
+ response = send_request({ cmd: "least_touched", limit: 5 })
108
+ expect(response).to eq({ ok: true, value: [["a", 0]] })
109
+ expect(Mudis).to have_received(:least_touched).with(5)
110
+ end
111
+
112
+ it "handles the 'all_keys' command" do
113
+ response = send_request({ cmd: "all_keys" })
114
+ expect(response).to eq({ ok: true, value: ["k1"] })
115
+ expect(Mudis).to have_received(:all_keys)
116
+ end
117
+
118
+ it "handles the 'current_memory_bytes' command" do
119
+ response = send_request({ cmd: "current_memory_bytes" })
120
+ expect(response).to eq({ ok: true, value: 123 })
121
+ expect(Mudis).to have_received(:current_memory_bytes)
122
+ end
123
+
124
+ it "handles the 'max_memory_bytes' command" do
125
+ response = send_request({ cmd: "max_memory_bytes" })
126
+ expect(response).to eq({ ok: true, value: 456 })
127
+ expect(Mudis).to have_received(:max_memory_bytes)
128
+ end
129
+
130
+ it "handles the 'metrics' command" do
131
+ response = send_request({ cmd: "metrics" })
132
+ expect(response).to eq({ ok: true, value: { reads: 1, writes: 1 } })
133
+ expect(Mudis).to have_received(:metrics)
93
134
  end
94
135
 
95
136
  it "handles unknown commands" do
data/spec/mudis_spec.rb CHANGED
@@ -55,6 +55,19 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
55
55
  Mudis.update("counter") { |v| v + 1 }
56
56
  expect(Mudis.read("counter")).to eq(6)
57
57
  end
58
+
59
+ it "refreshes TTL based on original duration" do
60
+ Mudis.write("ttl_key", "v", expires_in: 2)
61
+ meta_before = Mudis.inspect("ttl_key")
62
+ original_ttl = meta_before[:expires_at] - meta_before[:created_at]
63
+
64
+ sleep 1
65
+ Mudis.update("ttl_key") { |v| v }
66
+ meta_after = Mudis.inspect("ttl_key")
67
+
68
+ expect(meta_after[:created_at]).to be > meta_before[:created_at]
69
+ expect(meta_after[:expires_at]).to be_within(0.5).of(Time.now + original_ttl)
70
+ end
58
71
  end
59
72
 
60
73
  describe ".fetch" do
@@ -76,6 +89,30 @@ RSpec.describe Mudis do # rubocop:disable Metrics/BlockLength
76
89
  result = Mudis.fetch("k", force: true) { 200 } # fix
77
90
  expect(result).to eq(200)
78
91
  end
92
+
93
+ it "executes the block once with singleflight: true" do
94
+ Mudis.delete("sf")
95
+ count = 0
96
+ count_mutex = Mutex.new
97
+ results = []
98
+ results_mutex = Mutex.new
99
+
100
+ threads = 5.times.map do
101
+ Thread.new do
102
+ value = Mudis.fetch("sf", singleflight: true) do
103
+ count_mutex.synchronize { count += 1 }
104
+ sleep 0.05
105
+ "v"
106
+ end
107
+ results_mutex.synchronize { results << value }
108
+ end
109
+ end
110
+
111
+ threads.each(&:join)
112
+ expect(count).to eq(1)
113
+ expect(results).to all(eq("v"))
114
+ expect(Mudis.read("sf")).to eq("v")
115
+ end
79
116
  end
80
117
 
81
118
  describe ".clear" do
@@ -23,6 +23,29 @@ RSpec.describe "Mudis Namespace Operations" do # rubocop:disable Metrics/BlockLe
23
23
  expect(Mudis.read("x")).to be_nil
24
24
  end
25
25
 
26
+ it "does not double-prefix keys in exists? under thread namespace" do
27
+ Mudis.with_namespace("ns") do
28
+ Mudis.write("k", "v")
29
+ expect(Mudis.exists?("k")).to be true
30
+ end
31
+ end
32
+
33
+ it "does not double-prefix keys in fetch under thread namespace" do
34
+ Mudis.with_namespace("ns") do
35
+ value = Mudis.fetch("k") { "v" }
36
+ expect(value).to eq("v")
37
+ expect(Mudis.read("k")).to eq("v")
38
+ end
39
+ end
40
+
41
+ it "does not double-prefix keys in replace under thread namespace" do
42
+ Mudis.with_namespace("ns") do
43
+ Mudis.write("k", "v")
44
+ Mudis.replace("k", "v2")
45
+ expect(Mudis.read("k")).to eq("v2")
46
+ end
47
+ end
48
+
26
49
  describe ".keys" do
27
50
  it "returns only keys for the given namespace" do
28
51
  Mudis.write("user:1", "Alice", namespace: "users")