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.
@@ -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
@@ -3,8 +3,9 @@
3
3
  require_relative "spec_helper"
4
4
 
5
5
  RSpec.describe MudisClient do # rubocop:disable Metrics/BlockLength
6
- let(:socket_path) { "/tmp/mudis.sock" }
7
- let(:mock_socket) { instance_double(UNIXSocket) }
6
+ let(:socket_path) { MudisIPCConfig::SOCKET_PATH }
7
+ let(:socket_class) { MudisIPCConfig.use_tcp? ? TCPSocket : UNIXSocket }
8
+ let(:mock_socket) { instance_double(socket_class) }
8
9
  let(:client) { MudisClient.new }
9
10
 
10
11
  around do |example|
@@ -14,7 +15,12 @@ RSpec.describe MudisClient do # rubocop:disable Metrics/BlockLength
14
15
  end
15
16
 
16
17
  before do
17
- allow(UNIXSocket).to receive(:open).and_yield(mock_socket)
18
+ if MudisIPCConfig.use_tcp?
19
+ allow(TCPSocket).to receive(:new).and_return(mock_socket)
20
+ else
21
+ allow(UNIXSocket).to receive(:open).and_yield(mock_socket)
22
+ end
23
+ allow(mock_socket).to receive(:close)
18
24
  end
19
25
 
20
26
  describe "#read" do
@@ -119,9 +125,13 @@ RSpec.describe MudisClient do # rubocop:disable Metrics/BlockLength
119
125
 
120
126
  describe "error handling" do
121
127
  it "warns when the socket is missing" do
122
- allow(UNIXSocket).to receive(:open).and_raise(Errno::ENOENT)
128
+ if MudisIPCConfig.use_tcp?
129
+ allow(TCPSocket).to receive(:new).and_raise(Errno::ECONNREFUSED)
130
+ else
131
+ allow(UNIXSocket).to receive(:open).and_raise(Errno::ENOENT)
132
+ end
123
133
 
124
- expect { client.read("test_key") }.to output(/Socket missing/).to_stderr
134
+ expect { client.read("test_key") }.to output(/Cannot connect/).to_stderr
125
135
  expect(client.read("test_key")).to be_nil
126
136
  end
127
137
 
@@ -6,13 +6,15 @@ 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 }
10
+
9
11
  before(:all) do
10
- skip "UNIX sockets not supported on Windows" if Gem.win_platform?
12
+ # Start the server once for all tests
13
+ Thread.new { MudisServer.start! }
14
+ sleep 0.2 # Allow the server to start
11
15
  end
12
16
 
13
- let(:socket_path) { MudisServer::SOCKET_PATH }
14
-
15
- before do
17
+ before(:each) do
16
18
  allow(Mudis).to receive(:read).and_return("mock_value")
17
19
  allow(Mudis).to receive(:write)
18
20
  allow(Mudis).to receive(:delete)
@@ -21,20 +23,23 @@ RSpec.describe MudisServer do # rubocop:disable Metrics/BlockLength
21
23
  allow(Mudis).to receive(:metrics).and_return({ reads: 1, writes: 1 })
22
24
  allow(Mudis).to receive(:reset_metrics!)
23
25
  allow(Mudis).to receive(:reset!)
24
-
25
- # Start the server in a separate thread
26
- Thread.new { MudisServer.start! }
27
- sleep 0.1 # Allow the server to start
28
26
  end
29
27
 
30
28
  after do
31
- File.unlink(socket_path) if File.exist?(socket_path)
29
+ File.unlink(socket_path) if File.exist?(socket_path) && !MudisIPCConfig.use_tcp?
32
30
  end
33
31
 
34
32
  def send_request(request)
35
- UNIXSocket.open(socket_path) do |sock|
36
- sock.puts(JSON.dump(request))
37
- JSON.parse(sock.gets, symbolize_names: true)
33
+ if MudisIPCConfig.use_tcp?
34
+ TCPSocket.open(MudisIPCConfig::TCP_HOST, MudisIPCConfig::TCP_PORT) do |sock|
35
+ sock.puts(JSON.dump(request))
36
+ JSON.parse(sock.gets, symbolize_names: true)
37
+ end
38
+ else
39
+ UNIXSocket.open(socket_path) do |sock|
40
+ sock.puts(JSON.dump(request))
41
+ JSON.parse(sock.gets, symbolize_names: true)
42
+ end
38
43
  end
39
44
  end
40
45
 
@@ -46,13 +51,13 @@ RSpec.describe MudisServer do # rubocop:disable Metrics/BlockLength
46
51
 
47
52
  it "handles the 'write' command" do
48
53
  response = send_request({ cmd: "write", key: "test_key", value: "test_value", ttl: 60, namespace: "test_ns" })
49
- expect(response).to eq({ ok: true })
54
+ expect(response).to eq({ ok: true, value: nil })
50
55
  expect(Mudis).to have_received(:write).with("test_key", "test_value", expires_in: 60, namespace: "test_ns")
51
56
  end
52
57
 
53
58
  it "handles the 'delete' command" do
54
59
  response = send_request({ cmd: "delete", key: "test_key", namespace: "test_ns" })
55
- expect(response).to eq({ ok: true })
60
+ expect(response).to eq({ ok: true, value: nil })
56
61
  expect(Mudis).to have_received(:delete).with("test_key", namespace: "test_ns")
57
62
  end
58
63
 
@@ -77,18 +82,19 @@ RSpec.describe MudisServer do # rubocop:disable Metrics/BlockLength
77
82
 
78
83
  it "handles the 'reset_metrics' command" do
79
84
  response = send_request({ cmd: "reset_metrics" })
80
- expect(response).to eq({ ok: true })
85
+ expect(response).to eq({ ok: true, value: nil })
81
86
  expect(Mudis).to have_received(:reset_metrics!)
82
87
  end
83
88
 
84
89
  it "handles the 'reset' command" do
85
90
  response = send_request({ cmd: "reset" })
86
- expect(response).to eq({ ok: true })
91
+ expect(response).to eq({ ok: true, value: nil })
87
92
  expect(Mudis).to have_received(:reset!)
88
93
  end
89
94
 
90
95
  it "handles unknown commands" do
91
96
  response = send_request({ cmd: "unknown_command" })
92
- expect(response).to eq({ ok: false, error: "unknown command: unknown_command" })
97
+ expect(response[:ok]).to be false
98
+ expect(response[:error]).to match(/unknown command/i)
93
99
  end
94
100
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mudis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kiebor81
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-10-26 00:00:00.000000000 Z
11
+ date: 2025-12-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '3.12'
41
+ - !ruby/object:Gem::Dependency
42
+ name: simplecov
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.22'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.22'
41
55
  description: Mudis is a fast, thread-safe, in-memory, sharded LRU cache for Ruby applications.
42
56
  Inspired by Redis, it provides value serialization, optional compression, per-key
43
57
  expiry, and metric tracking in a lightweight, dependency-free package.
@@ -48,23 +62,47 @@ extra_rdoc_files:
48
62
  - sig/mudis.rbs
49
63
  - sig/mudis_client.rbs
50
64
  - sig/mudis_config.rbs
65
+ - sig/mudis_expiry.rbs
66
+ - sig/mudis_ipc_config.rbs
67
+ - sig/mudis_lru.rbs
68
+ - sig/mudis_metrics.rbs
69
+ - sig/mudis_namespace.rbs
70
+ - sig/mudis_persistence.rbs
51
71
  - sig/mudis_server.rbs
52
72
  files:
53
73
  - README.md
54
74
  - lib/mudis.rb
75
+ - lib/mudis/expiry.rb
76
+ - lib/mudis/lru.rb
77
+ - lib/mudis/metrics.rb
78
+ - lib/mudis/namespace.rb
79
+ - lib/mudis/persistence.rb
55
80
  - lib/mudis/version.rb
56
81
  - lib/mudis_client.rb
57
82
  - lib/mudis_config.rb
83
+ - lib/mudis_ipc_config.rb
58
84
  - lib/mudis_proxy.rb
59
85
  - lib/mudis_server.rb
60
86
  - sig/mudis.rbs
61
87
  - sig/mudis_client.rbs
62
88
  - sig/mudis_config.rbs
89
+ - sig/mudis_expiry.rbs
90
+ - sig/mudis_ipc_config.rbs
91
+ - sig/mudis_lru.rbs
92
+ - sig/mudis_metrics.rbs
93
+ - sig/mudis_namespace.rbs
94
+ - sig/mudis_persistence.rbs
63
95
  - sig/mudis_server.rbs
96
+ - spec/api_compatibility_spec.rb
64
97
  - spec/eviction_spec.rb
65
98
  - spec/guardrails_spec.rb
66
99
  - spec/memory_guard_spec.rb
67
100
  - spec/metrics_spec.rb
101
+ - spec/modules/expiry_spec.rb
102
+ - spec/modules/lru_spec.rb
103
+ - spec/modules/metrics_spec.rb
104
+ - spec/modules/namespace_spec.rb
105
+ - spec/modules/persistence_spec.rb
68
106
  - spec/mudis_client_spec.rb
69
107
  - spec/mudis_server_spec.rb
70
108
  - spec/mudis_spec.rb
@@ -96,10 +134,16 @@ specification_version: 4
96
134
  summary: A fast in-memory, thread-safe and high performance Ruby LRU cache with compression
97
135
  and auto-expiry.
98
136
  test_files:
137
+ - spec/api_compatibility_spec.rb
99
138
  - spec/eviction_spec.rb
100
139
  - spec/guardrails_spec.rb
101
140
  - spec/memory_guard_spec.rb
102
141
  - spec/metrics_spec.rb
142
+ - spec/modules/expiry_spec.rb
143
+ - spec/modules/lru_spec.rb
144
+ - spec/modules/metrics_spec.rb
145
+ - spec/modules/namespace_spec.rb
146
+ - spec/modules/persistence_spec.rb
103
147
  - spec/mudis_client_spec.rb
104
148
  - spec/mudis_server_spec.rb
105
149
  - spec/mudis_spec.rb