mudis 0.5.0 → 0.7.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 +695 -568
- data/lib/example_mudis_server_config.md +39 -0
- data/lib/mudis/version.rb +3 -3
- data/lib/mudis.rb +521 -497
- data/lib/mudis_client.rb +68 -0
- data/lib/mudis_config.rb +25 -25
- data/lib/mudis_server.rb +79 -0
- data/sig/mudis.rbs +56 -54
- data/sig/mudis_config.rbs +10 -10
- data/spec/eviction_spec.rb +29 -0
- data/spec/guardrails_spec.rb +138 -138
- data/spec/memory_guard_spec.rb +33 -0
- data/spec/metrics_spec.rb +34 -0
- data/spec/mudis_spec.rb +183 -314
- data/spec/namespace_spec.rb +69 -0
- data/spec/reset_spec.rb +31 -0
- metadata +16 -7
data/lib/mudis_client.rb
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "socket"
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
class MudisClient
|
6
|
+
SOCKET_PATH = "/tmp/mudis.sock"
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@mutex = Mutex.new
|
10
|
+
end
|
11
|
+
|
12
|
+
def request(payload)
|
13
|
+
@mutex.synchronize do
|
14
|
+
UNIXSocket.open(SOCKET_PATH) do |sock|
|
15
|
+
sock.puts(JSON.dump(payload))
|
16
|
+
response = sock.gets
|
17
|
+
res = JSON.parse(response, symbolize_names: true)
|
18
|
+
raise res[:error] unless res[:ok]
|
19
|
+
res[:value]
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
rescue Errno::ENOENT
|
24
|
+
warn "[MudisClient] Socket missing; master likely not running MudisServer"
|
25
|
+
nil
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
|
31
|
+
def read(key, namespace: nil)
|
32
|
+
request(cmd: "read", key: key, namespace: namespace)
|
33
|
+
end
|
34
|
+
|
35
|
+
def write(key, value, expires_in: nil, namespace: nil)
|
36
|
+
request(cmd: "write", key: key, value: value, ttl: expires_in, namespace: namespace)
|
37
|
+
end
|
38
|
+
|
39
|
+
def delete(key, namespace: nil)
|
40
|
+
request(cmd: "delete", key: key, namespace: namespace)
|
41
|
+
end
|
42
|
+
|
43
|
+
def exists?(key, namespace: nil)
|
44
|
+
request(cmd: "exists", key: key, namespace: namespace)
|
45
|
+
end
|
46
|
+
|
47
|
+
def fetch(key, expires_in: nil, namespace: nil)
|
48
|
+
val = read(key, namespace: namespace)
|
49
|
+
return val if val
|
50
|
+
|
51
|
+
new_val = yield
|
52
|
+
write(key, new_val, expires_in: expires_in, namespace: namespace)
|
53
|
+
new_val
|
54
|
+
end
|
55
|
+
|
56
|
+
def metrics
|
57
|
+
request(cmd: "metrics")
|
58
|
+
end
|
59
|
+
|
60
|
+
def reset_metrics!
|
61
|
+
request(cmd: "reset_metrics")
|
62
|
+
end
|
63
|
+
|
64
|
+
def reset!
|
65
|
+
request(cmd: "reset")
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
data/lib/mudis_config.rb
CHANGED
@@ -1,25 +1,25 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
# MudisConfig holds all configuration values for Mudis,
|
4
|
-
# and provides defaults that can be overridden via Mudis.configure.
|
5
|
-
class MudisConfig
|
6
|
-
attr_accessor :serializer,
|
7
|
-
:compress,
|
8
|
-
:max_value_bytes,
|
9
|
-
:hard_memory_limit,
|
10
|
-
:max_bytes,
|
11
|
-
:buckets,
|
12
|
-
:max_ttl,
|
13
|
-
:default_ttl
|
14
|
-
|
15
|
-
def initialize
|
16
|
-
@serializer = JSON # Default serialization strategy
|
17
|
-
@compress = false # Whether to compress values with Zlib
|
18
|
-
@max_value_bytes = nil # Max size per value (optional)
|
19
|
-
@hard_memory_limit = false # Enforce max_bytes as hard cap
|
20
|
-
@max_bytes = 1_073_741_824 # 1 GB default max cache size
|
21
|
-
@buckets = nil # use nil to signal fallback to ENV or default
|
22
|
-
@max_ttl = nil # Max TTL for cache entries (optional)
|
23
|
-
@default_ttl = nil # Default TTL for cache entries (optional)
|
24
|
-
end
|
25
|
-
end
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# MudisConfig holds all configuration values for Mudis,
|
4
|
+
# and provides defaults that can be overridden via Mudis.configure.
|
5
|
+
class MudisConfig
|
6
|
+
attr_accessor :serializer,
|
7
|
+
:compress,
|
8
|
+
:max_value_bytes,
|
9
|
+
:hard_memory_limit,
|
10
|
+
:max_bytes,
|
11
|
+
:buckets,
|
12
|
+
:max_ttl,
|
13
|
+
:default_ttl
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@serializer = JSON # Default serialization strategy
|
17
|
+
@compress = false # Whether to compress values with Zlib
|
18
|
+
@max_value_bytes = nil # Max size per value (optional)
|
19
|
+
@hard_memory_limit = false # Enforce max_bytes as hard cap
|
20
|
+
@max_bytes = 1_073_741_824 # 1 GB default max cache size
|
21
|
+
@buckets = nil # use nil to signal fallback to ENV or default
|
22
|
+
@max_ttl = nil # Max TTL for cache entries (optional)
|
23
|
+
@default_ttl = nil # Default TTL for cache entries (optional)
|
24
|
+
end
|
25
|
+
end
|
data/lib/mudis_server.rb
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "socket"
|
3
|
+
require "json"
|
4
|
+
require_relative "mudis"
|
5
|
+
|
6
|
+
class MudisServer
|
7
|
+
SOCKET_PATH = "/tmp/mudis.sock"
|
8
|
+
|
9
|
+
def self.start!
|
10
|
+
# Clean up old socket if it exists
|
11
|
+
File.unlink(SOCKET_PATH) if File.exist?(SOCKET_PATH)
|
12
|
+
|
13
|
+
server = UNIXServer.new(SOCKET_PATH)
|
14
|
+
server.listen(128)
|
15
|
+
puts "[MudisServer] Listening on #{SOCKET_PATH}"
|
16
|
+
|
17
|
+
Thread.new do
|
18
|
+
loop do
|
19
|
+
client = server.accept
|
20
|
+
Thread.new(client) do |sock|
|
21
|
+
handle_client(sock)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.handle_client(sock)
|
28
|
+
request_line = sock.gets
|
29
|
+
return unless request_line
|
30
|
+
|
31
|
+
req = JSON.parse(request_line, symbolize_names: true)
|
32
|
+
cmd = req[:cmd]
|
33
|
+
key = req[:key]
|
34
|
+
ns = req[:namespace]
|
35
|
+
val = req[:value]
|
36
|
+
ttl = req[:ttl]
|
37
|
+
|
38
|
+
begin
|
39
|
+
case cmd
|
40
|
+
when "read"
|
41
|
+
result = Mudis.read(key, namespace: ns)
|
42
|
+
sock.puts(JSON.dump({ ok: true, value: result }))
|
43
|
+
|
44
|
+
when "write"
|
45
|
+
Mudis.write(key, val, expires_in: ttl, namespace: ns)
|
46
|
+
sock.puts(JSON.dump({ ok: true }))
|
47
|
+
|
48
|
+
when "delete"
|
49
|
+
Mudis.delete(key, namespace: ns)
|
50
|
+
sock.puts(JSON.dump({ ok: true }))
|
51
|
+
|
52
|
+
when "exists"
|
53
|
+
sock.puts(JSON.dump({ ok: true, value: Mudis.exists?(key, namespace: ns) }))
|
54
|
+
|
55
|
+
when "fetch"
|
56
|
+
result = Mudis.fetch(key, expires_in: ttl, namespace: ns) { req[:fallback] }
|
57
|
+
sock.puts(JSON.dump({ ok: true, value: result }))
|
58
|
+
|
59
|
+
when "metrics"
|
60
|
+
sock.puts(JSON.dump({ ok: true, value: Mudis.metrics }))
|
61
|
+
|
62
|
+
when "reset_metrics"
|
63
|
+
Mudis.reset_metrics!
|
64
|
+
sock.puts(JSON.dump({ ok: true }))
|
65
|
+
|
66
|
+
when "reset"
|
67
|
+
Mudis.reset!
|
68
|
+
sock.puts(JSON.dump({ ok: true }))
|
69
|
+
|
70
|
+
else
|
71
|
+
sock.puts(JSON.dump({ ok: false, error: "unknown command: #{cmd}" }))
|
72
|
+
end
|
73
|
+
rescue => e
|
74
|
+
sock.puts(JSON.dump({ ok: false, error: e.message }))
|
75
|
+
ensure
|
76
|
+
sock.close
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
data/sig/mudis.rbs
CHANGED
@@ -1,54 +1,56 @@
|
|
1
|
-
class Mudis
|
2
|
-
# Configuration
|
3
|
-
class << self
|
4
|
-
attr_accessor serializer : Object
|
5
|
-
attr_accessor compress : bool
|
6
|
-
attr_accessor hard_memory_limit : bool
|
7
|
-
attr_reader max_bytes : Integer
|
8
|
-
attr_reader max_value_bytes : Integer?
|
9
|
-
attr_accessor max_ttl: Integer?
|
10
|
-
attr_accessor default_ttl: Integer?
|
11
|
-
|
12
|
-
def configure: () { (config: MudisConfig) -> void } -> void
|
13
|
-
def config: () -> MudisConfig
|
14
|
-
def apply_config!: () -> void
|
15
|
-
def validate_config!: () -> void
|
16
|
-
|
17
|
-
def buckets: () -> Integer
|
18
|
-
end
|
19
|
-
|
20
|
-
# Lifecycle
|
21
|
-
def self.start_expiry_thread: (?interval: Integer) -> void
|
22
|
-
def self.stop_expiry_thread: () -> void
|
23
|
-
|
24
|
-
# Core operations
|
25
|
-
def self.write: (String, untyped, ?expires_in: Integer, ?namespace: String) -> void
|
26
|
-
def self.read: (String, ?namespace: String) -> untyped?
|
27
|
-
def self.update: (String, ?namespace: String) { (untyped) -> untyped } -> void
|
28
|
-
def self.delete: (String, ?namespace: String) -> void
|
29
|
-
def self.exists?: (String, ?namespace: String) -> bool
|
30
|
-
|
31
|
-
# DSL & Helpers
|
32
|
-
def self.fetch: (
|
33
|
-
String,
|
34
|
-
?expires_in: Integer,
|
35
|
-
?force: bool,
|
36
|
-
?namespace: String
|
37
|
-
) { () -> untyped } -> untyped
|
38
|
-
|
39
|
-
def self.clear: (String, ?namespace: String) -> void
|
40
|
-
def self.replace: (String, untyped, ?expires_in: Integer, ?namespace: String) -> void
|
41
|
-
def self.inspect: (String, ?namespace: String) -> Hash[Symbol, untyped]?
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
def self.
|
47
|
-
def self.
|
48
|
-
def self.
|
49
|
-
def self.
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
1
|
+
class Mudis
|
2
|
+
# Configuration
|
3
|
+
class << self
|
4
|
+
attr_accessor serializer : Object
|
5
|
+
attr_accessor compress : bool
|
6
|
+
attr_accessor hard_memory_limit : bool
|
7
|
+
attr_reader max_bytes : Integer
|
8
|
+
attr_reader max_value_bytes : Integer?
|
9
|
+
attr_accessor max_ttl: Integer?
|
10
|
+
attr_accessor default_ttl: Integer?
|
11
|
+
|
12
|
+
def configure: () { (config: MudisConfig) -> void } -> void
|
13
|
+
def config: () -> MudisConfig
|
14
|
+
def apply_config!: () -> void
|
15
|
+
def validate_config!: () -> void
|
16
|
+
|
17
|
+
def buckets: () -> Integer
|
18
|
+
end
|
19
|
+
|
20
|
+
# Lifecycle
|
21
|
+
def self.start_expiry_thread: (?interval: Integer) -> void
|
22
|
+
def self.stop_expiry_thread: () -> void
|
23
|
+
|
24
|
+
# Core operations
|
25
|
+
def self.write: (String, untyped, ?expires_in: Integer, ?namespace: String) -> void
|
26
|
+
def self.read: (String, ?namespace: String) -> untyped?
|
27
|
+
def self.update: (String, ?namespace: String) { (untyped) -> untyped } -> void
|
28
|
+
def self.delete: (String, ?namespace: String) -> void
|
29
|
+
def self.exists?: (String, ?namespace: String) -> bool
|
30
|
+
|
31
|
+
# DSL & Helpers
|
32
|
+
def self.fetch: (
|
33
|
+
String,
|
34
|
+
?expires_in: Integer,
|
35
|
+
?force: bool,
|
36
|
+
?namespace: String
|
37
|
+
) { () -> untyped } -> untyped
|
38
|
+
|
39
|
+
def self.clear: (String, ?namespace: String) -> void
|
40
|
+
def self.replace: (String, untyped, ?expires_in: Integer, ?namespace: String) -> void
|
41
|
+
def self.inspect: (String, ?namespace: String) -> Hash[Symbol, untyped]?
|
42
|
+
def self.keys: (?namespace: String) -> Array[String]
|
43
|
+
def self.clear_namespace: (?namespace: String) -> void
|
44
|
+
|
45
|
+
# Introspection & management
|
46
|
+
def self.metrics: () -> Hash[Symbol, untyped]
|
47
|
+
def self.cleanup_expired!: () -> void
|
48
|
+
def self.all_keys: () -> Array[String]
|
49
|
+
def self.current_memory_bytes: () -> Integer
|
50
|
+
def self.max_memory_bytes: () -> Integer
|
51
|
+
def self.least_touched: (?Integer) -> Array[[String, Integer]]
|
52
|
+
|
53
|
+
# State reset
|
54
|
+
def self.reset!: () -> void
|
55
|
+
def self.reset_metrics!: () -> void
|
56
|
+
end
|
data/sig/mudis_config.rbs
CHANGED
@@ -1,10 +1,10 @@
|
|
1
|
-
class MudisConfig
|
2
|
-
attr_accessor serializer: Object
|
3
|
-
attr_accessor compress: bool
|
4
|
-
attr_accessor max_value_bytes: Integer?
|
5
|
-
attr_accessor hard_memory_limit: bool
|
6
|
-
attr_accessor max_bytes: Integer
|
7
|
-
attr_accessor max_ttl: Integer?
|
8
|
-
attr_accessor default_ttl: Integer?
|
9
|
-
attr_accessor buckets: Integer?
|
10
|
-
end
|
1
|
+
class MudisConfig
|
2
|
+
attr_accessor serializer: Object
|
3
|
+
attr_accessor compress: bool
|
4
|
+
attr_accessor max_value_bytes: Integer?
|
5
|
+
attr_accessor hard_memory_limit: bool
|
6
|
+
attr_accessor max_bytes: Integer
|
7
|
+
attr_accessor max_ttl: Integer?
|
8
|
+
attr_accessor default_ttl: Integer?
|
9
|
+
attr_accessor buckets: Integer?
|
10
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe "Mudis LRU Eviction" do
|
6
|
+
before do
|
7
|
+
Mudis.reset!
|
8
|
+
Mudis.stop_expiry_thread
|
9
|
+
|
10
|
+
Mudis.instance_variable_set(:@buckets, 1)
|
11
|
+
Mudis.instance_variable_set(:@stores, [{}])
|
12
|
+
Mudis.instance_variable_set(:@mutexes, [Mutex.new])
|
13
|
+
Mudis.instance_variable_set(:@lru_heads, [nil])
|
14
|
+
Mudis.instance_variable_set(:@lru_tails, [nil])
|
15
|
+
Mudis.instance_variable_set(:@lru_nodes, [{}])
|
16
|
+
Mudis.instance_variable_set(:@current_bytes, [0])
|
17
|
+
Mudis.hard_memory_limit = false
|
18
|
+
Mudis.instance_variable_set(:@threshold_bytes, 60)
|
19
|
+
Mudis.max_value_bytes = 100
|
20
|
+
end
|
21
|
+
|
22
|
+
it "evicts old entries when size limit is reached" do
|
23
|
+
Mudis.write("a", "a" * 50)
|
24
|
+
Mudis.write("b", "b" * 50)
|
25
|
+
|
26
|
+
expect(Mudis.read("a")).to be_nil
|
27
|
+
expect(Mudis.read("b")).not_to be_nil
|
28
|
+
end
|
29
|
+
end
|
data/spec/guardrails_spec.rb
CHANGED
@@ -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
|
@@ -0,0 +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
|