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.
@@ -1,138 +1,138 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
- require "climate_control"
5
-
6
- RSpec.describe "Mudis TTL Guardrail" do # rubocop:disable Metrics/BlockLength
7
- before do
8
- Mudis.reset!
9
- Mudis.configure do |c|
10
- c.max_ttl = 60 # 60 seconds max
11
- end
12
- end
13
-
14
- describe "default_ttl configuration" do # rubocop:disable Metrics/BlockLength
15
- before do
16
- Mudis.reset!
17
- Mudis.configure do |c|
18
- c.default_ttl = 60
19
- end
20
- end
21
-
22
- it "applies default_ttl when expires_in is nil" do
23
- Mudis.write("foo", "bar") # no explicit expires_in
24
- meta = Mudis.inspect("foo")
25
- expect(meta[:expires_at]).not_to be_nil
26
- expect(meta[:expires_at]).to be_within(5).of(Time.now + 60)
27
- end
28
-
29
- it "respects expires_in if explicitly given" do
30
- Mudis.write("short_lived", "bar", expires_in: 10)
31
- meta = Mudis.inspect("short_lived")
32
- expect(meta[:expires_at]).not_to be_nil
33
- expect(meta[:expires_at]).to be_within(5).of(Time.now + 10)
34
- end
35
-
36
- it "applies max_ttl over default_ttl if both are set" do
37
- Mudis.configure do |c|
38
- c.default_ttl = 120
39
- c.max_ttl = 30
40
- end
41
-
42
- Mudis.write("capped", "baz") # no explicit expires_in
43
- meta = Mudis.inspect("capped")
44
- expect(meta[:expires_at]).not_to be_nil
45
- expect(meta[:expires_at]).to be_within(5).of(Time.now + 30)
46
- end
47
-
48
- it "stores forever if default_ttl and expires_in are nil" do
49
- Mudis.configure do |c|
50
- c.default_ttl = nil
51
- end
52
-
53
- Mudis.write("forever", "ever")
54
- meta = Mudis.inspect("forever")
55
- expect(meta[:expires_at]).to be_nil
56
- end
57
- end
58
-
59
- it "clamps expires_in to max_ttl if it exceeds max_ttl" do
60
- Mudis.write("foo", "bar", expires_in: 300) # user requests 5 minutes
61
-
62
- metadata = Mudis.inspect("foo")
63
- ttl = metadata[:expires_at] - metadata[:created_at]
64
-
65
- expect(ttl).to be <= 60
66
- expect(ttl).to be > 0
67
- end
68
-
69
- it "respects expires_in if below max_ttl" do
70
- Mudis.write("bar", "baz", expires_in: 30) # under the max_ttl
71
-
72
- metadata = Mudis.inspect("bar")
73
- ttl = metadata[:expires_at] - metadata[:created_at]
74
-
75
- expect(ttl).to be_within(1).of(30)
76
- end
77
-
78
- it "allows nil expires_in (no expiry) if not required" do
79
- Mudis.write("baz", "no expiry")
80
-
81
- metadata = Mudis.inspect("baz")
82
- expect(metadata[:expires_at]).to be_nil
83
- end
84
- end
85
-
86
- RSpec.describe "Mudis Configuration Guardrails" do # rubocop:disable Metrics/BlockLength
87
- after { Mudis.reset! }
88
-
89
- describe "bucket configuration" do
90
- it "defaults to 32 buckets if ENV is nil" do
91
- Mudis.instance_variable_set(:@buckets, nil) # force recomputation
92
- ClimateControl.modify(MUDIS_BUCKETS: nil) do
93
- expect(Mudis.send(:buckets)).to eq(32)
94
- end
95
- end
96
-
97
- it "raises if MUDIS_BUCKETS is 0 or less" do
98
- expect do
99
- Mudis.instance_variable_set(:@buckets, nil) # force recomputation
100
- ClimateControl.modify(MUDIS_BUCKETS: "0") { Mudis.send(:buckets) }
101
- end.to raise_error(ArgumentError, /bucket count must be > 0/)
102
-
103
- expect do
104
- Mudis.instance_variable_set(:@buckets, nil) # force recomputation
105
- ClimateControl.modify(MUDIS_BUCKETS: "-5") { Mudis.send(:buckets) }
106
- end.to raise_error(ArgumentError, /bucket count must be > 0/)
107
- end
108
- end
109
-
110
- describe "memory configuration" do
111
- it "raises if max_bytes is set to 0 or less" do
112
- expect do
113
- Mudis.max_bytes = 0
114
- end.to raise_error(ArgumentError, /max_bytes must be > 0/)
115
-
116
- expect do
117
- Mudis.max_bytes = -1
118
- end.to raise_error(ArgumentError, /max_bytes must be > 0/)
119
- end
120
-
121
- it "raises if max_value_bytes is 0 or less via config" do
122
- expect do
123
- Mudis.configure do |c|
124
- c.max_value_bytes = 0
125
- end
126
- end.to raise_error(ArgumentError, /max_value_bytes must be > 0/)
127
- end
128
-
129
- it "raises if max_value_bytes exceeds max_bytes" do
130
- expect do
131
- Mudis.configure do |c|
132
- c.max_bytes = 1_000_000
133
- c.max_value_bytes = 2_000_000
134
- end
135
- end.to raise_error(ArgumentError, /max_value_bytes cannot exceed max_bytes/)
136
- end
137
- end
138
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "climate_control"
5
+
6
+ RSpec.describe "Mudis TTL Guardrail" do # rubocop:disable Metrics/BlockLength
7
+ before do
8
+ Mudis.reset!
9
+ Mudis.configure do |c|
10
+ c.max_ttl = 60 # 60 seconds max
11
+ end
12
+ end
13
+
14
+ describe "default_ttl configuration" do # rubocop:disable Metrics/BlockLength
15
+ before do
16
+ Mudis.reset!
17
+ Mudis.configure do |c|
18
+ c.default_ttl = 60
19
+ end
20
+ end
21
+
22
+ it "applies default_ttl when expires_in is nil" do
23
+ Mudis.write("foo", "bar") # no explicit expires_in
24
+ meta = Mudis.inspect("foo")
25
+ expect(meta[:expires_at]).not_to be_nil
26
+ expect(meta[:expires_at]).to be_within(5).of(Time.now + 60)
27
+ end
28
+
29
+ it "respects expires_in if explicitly given" do
30
+ Mudis.write("short_lived", "bar", expires_in: 10)
31
+ meta = Mudis.inspect("short_lived")
32
+ expect(meta[:expires_at]).not_to be_nil
33
+ expect(meta[:expires_at]).to be_within(5).of(Time.now + 10)
34
+ end
35
+
36
+ it "applies max_ttl over default_ttl if both are set" do
37
+ Mudis.configure do |c|
38
+ c.default_ttl = 120
39
+ c.max_ttl = 30
40
+ end
41
+
42
+ Mudis.write("capped", "baz") # no explicit expires_in
43
+ meta = Mudis.inspect("capped")
44
+ expect(meta[:expires_at]).not_to be_nil
45
+ expect(meta[:expires_at]).to be_within(5).of(Time.now + 30)
46
+ end
47
+
48
+ it "stores forever if default_ttl and expires_in are nil" do
49
+ Mudis.configure do |c|
50
+ c.default_ttl = nil
51
+ end
52
+
53
+ Mudis.write("forever", "ever")
54
+ meta = Mudis.inspect("forever")
55
+ expect(meta[:expires_at]).to be_nil
56
+ end
57
+ end
58
+
59
+ it "clamps expires_in to max_ttl if it exceeds max_ttl" do
60
+ Mudis.write("foo", "bar", expires_in: 300) # user requests 5 minutes
61
+
62
+ metadata = Mudis.inspect("foo")
63
+ ttl = metadata[:expires_at] - metadata[:created_at]
64
+
65
+ expect(ttl).to be <= 60
66
+ expect(ttl).to be > 0
67
+ end
68
+
69
+ it "respects expires_in if below max_ttl" do
70
+ Mudis.write("bar", "baz", expires_in: 30) # under the max_ttl
71
+
72
+ metadata = Mudis.inspect("bar")
73
+ ttl = metadata[:expires_at] - metadata[:created_at]
74
+
75
+ expect(ttl).to be_within(1).of(30)
76
+ end
77
+
78
+ it "allows nil expires_in (no expiry) if not required" do
79
+ Mudis.write("baz", "no expiry")
80
+
81
+ metadata = Mudis.inspect("baz")
82
+ expect(metadata[:expires_at]).to be_nil
83
+ end
84
+ end
85
+
86
+ RSpec.describe "Mudis Configuration Guardrails" do # rubocop:disable Metrics/BlockLength
87
+ after { Mudis.reset! }
88
+
89
+ describe "bucket configuration" do
90
+ it "defaults to 32 buckets if ENV is nil" do
91
+ Mudis.instance_variable_set(:@buckets, nil) # force recomputation
92
+ ClimateControl.modify(MUDIS_BUCKETS: nil) do
93
+ expect(Mudis.send(:buckets)).to eq(32)
94
+ end
95
+ end
96
+
97
+ it "raises if MUDIS_BUCKETS is 0 or less" do
98
+ expect do
99
+ Mudis.instance_variable_set(:@buckets, nil) # force recomputation
100
+ ClimateControl.modify(MUDIS_BUCKETS: "0") { Mudis.send(:buckets) }
101
+ end.to raise_error(ArgumentError, /bucket count must be > 0/)
102
+
103
+ expect do
104
+ Mudis.instance_variable_set(:@buckets, nil) # force recomputation
105
+ ClimateControl.modify(MUDIS_BUCKETS: "-5") { Mudis.send(:buckets) }
106
+ end.to raise_error(ArgumentError, /bucket count must be > 0/)
107
+ end
108
+ end
109
+
110
+ describe "memory configuration" do
111
+ it "raises if max_bytes is set to 0 or less" do
112
+ expect do
113
+ Mudis.max_bytes = 0
114
+ end.to raise_error(ArgumentError, /max_bytes must be > 0/)
115
+
116
+ expect do
117
+ Mudis.max_bytes = -1
118
+ end.to raise_error(ArgumentError, /max_bytes must be > 0/)
119
+ end
120
+
121
+ it "raises if max_value_bytes is 0 or less via config" do
122
+ expect do
123
+ Mudis.configure do |c|
124
+ c.max_value_bytes = 0
125
+ end
126
+ end.to raise_error(ArgumentError, /max_value_bytes must be > 0/)
127
+ end
128
+
129
+ it "raises if max_value_bytes exceeds max_bytes" do
130
+ expect do
131
+ Mudis.configure do |c|
132
+ c.max_bytes = 1_000_000
133
+ c.max_value_bytes = 2_000_000
134
+ end
135
+ end.to raise_error(ArgumentError, /max_value_bytes cannot exceed max_bytes/)
136
+ end
137
+ end
138
+ end
@@ -1,33 +1,33 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
-
5
- RSpec.describe "Mudis Memory Guardrails" do
6
- before do
7
- Mudis.reset!
8
- Mudis.stop_expiry_thread
9
- Mudis.instance_variable_set(:@buckets, 1)
10
- Mudis.instance_variable_set(:@stores, [{}])
11
- Mudis.instance_variable_set(:@mutexes, [Mutex.new])
12
- Mudis.instance_variable_set(:@lru_heads, [nil])
13
- Mudis.instance_variable_set(:@lru_tails, [nil])
14
- Mudis.instance_variable_set(:@lru_nodes, [{}])
15
- Mudis.instance_variable_set(:@current_bytes, [0])
16
-
17
- Mudis.max_value_bytes = nil
18
- Mudis.instance_variable_set(:@threshold_bytes, 1_000_000)
19
- Mudis.hard_memory_limit = true
20
- Mudis.instance_variable_set(:@max_bytes, 100)
21
- end
22
-
23
- it "rejects writes that exceed max memory" do
24
- big_value = "a" * 90
25
- Mudis.write("a", big_value)
26
- expect(Mudis.read("a")).to eq(big_value)
27
-
28
- big_value2 = "b" * 90
29
- Mudis.write("b", big_value2)
30
- expect(Mudis.read("b")).to be_nil
31
- expect(Mudis.metrics[:rejected]).to be > 0
32
- end
33
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Mudis Memory Guardrails" do
6
+ before do
7
+ Mudis.reset!
8
+ Mudis.stop_expiry_thread
9
+ Mudis.instance_variable_set(:@buckets, 1)
10
+ Mudis.instance_variable_set(:@stores, [{}])
11
+ Mudis.instance_variable_set(:@mutexes, [Mutex.new])
12
+ Mudis.instance_variable_set(:@lru_heads, [nil])
13
+ Mudis.instance_variable_set(:@lru_tails, [nil])
14
+ Mudis.instance_variable_set(:@lru_nodes, [{}])
15
+ Mudis.instance_variable_set(:@current_bytes, [0])
16
+
17
+ Mudis.max_value_bytes = nil
18
+ Mudis.instance_variable_set(:@threshold_bytes, 1_000_000)
19
+ Mudis.hard_memory_limit = true
20
+ Mudis.instance_variable_set(:@max_bytes, 100)
21
+ end
22
+
23
+ it "rejects writes that exceed max memory" do
24
+ big_value = "a" * 90
25
+ Mudis.write("a", big_value)
26
+ expect(Mudis.read("a")).to eq(big_value)
27
+
28
+ big_value2 = "b" * 90
29
+ Mudis.write("b", big_value2)
30
+ expect(Mudis.read("b")).to be_nil
31
+ expect(Mudis.metrics[:rejected]).to be > 0
32
+ end
33
+ end
data/spec/metrics_spec.rb CHANGED
@@ -1,34 +1,34 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "spec_helper"
4
-
5
- RSpec.describe "Mudis Metrics" do # rubocop:disable Metrics/BlockLength
6
- it "tracks hits and misses" do
7
- Mudis.write("hit_me", "value")
8
- Mudis.read("hit_me")
9
- Mudis.read("miss_me")
10
- metrics = Mudis.metrics
11
- expect(metrics[:hits]).to eq(1)
12
- expect(metrics[:misses]).to eq(1)
13
- end
14
-
15
- it "includes per-bucket stats" do
16
- Mudis.write("a", "x" * 50)
17
- metrics = Mudis.metrics
18
- expect(metrics).to include(:buckets)
19
- expect(metrics[:buckets]).to be_an(Array)
20
- expect(metrics[:buckets].first).to include(:index, :keys, :memory_bytes, :lru_size)
21
- end
22
-
23
- it "resets only the metrics without clearing cache" do
24
- Mudis.write("metrics_key", "value")
25
- Mudis.read("metrics_key")
26
- Mudis.read("missing_key")
27
- expect(Mudis.metrics[:hits]).to eq(1)
28
- expect(Mudis.metrics[:misses]).to eq(1)
29
- Mudis.reset_metrics!
30
- expect(Mudis.metrics[:hits]).to eq(0)
31
- expect(Mudis.metrics[:misses]).to eq(0)
32
- expect(Mudis.read("metrics_key")).to eq("value")
33
- end
34
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spec_helper"
4
+
5
+ RSpec.describe "Mudis Metrics" do # rubocop:disable Metrics/BlockLength
6
+ it "tracks hits and misses" do
7
+ Mudis.write("hit_me", "value")
8
+ Mudis.read("hit_me")
9
+ Mudis.read("miss_me")
10
+ metrics = Mudis.metrics
11
+ expect(metrics[:hits]).to eq(1)
12
+ expect(metrics[:misses]).to eq(1)
13
+ end
14
+
15
+ it "includes per-bucket stats" do
16
+ Mudis.write("a", "x" * 50)
17
+ metrics = Mudis.metrics
18
+ expect(metrics).to include(:buckets)
19
+ expect(metrics[:buckets]).to be_an(Array)
20
+ expect(metrics[:buckets].first).to include(:index, :keys, :memory_bytes, :lru_size)
21
+ end
22
+
23
+ it "resets only the metrics without clearing cache" do
24
+ Mudis.write("metrics_key", "value")
25
+ Mudis.read("metrics_key")
26
+ Mudis.read("missing_key")
27
+ expect(Mudis.metrics[:hits]).to eq(1)
28
+ expect(Mudis.metrics[:misses]).to eq(1)
29
+ Mudis.reset_metrics!
30
+ expect(Mudis.metrics[:hits]).to eq(0)
31
+ expect(Mudis.metrics[:misses]).to eq(0)
32
+ expect(Mudis.read("metrics_key")).to eq("value")
33
+ end
34
+ 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