mudis 0.9.1 → 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/lib/mudis_client.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "socket"
4
4
  require "json"
5
+ require "timeout"
5
6
  require_relative "mudis_ipc_config"
6
7
 
7
8
  # Thread-safe client for communicating with the MudisServer
@@ -25,28 +26,41 @@ class MudisClient
25
26
  # Send a request to the MudisServer and return the response
26
27
  # @param payload [Hash] The request payload
27
28
  # @return [Object] The response value from the server
28
- def request(payload) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
29
+ def request(payload) # rubocop:disable Metrics/MethodLength
29
30
  @mutex.synchronize do
30
- sock = open_connection
31
- sock.puts(JSON.dump(payload))
32
- response = sock.gets
33
- sock.close
34
-
35
- return nil unless response
36
-
37
- res = JSON.parse(response, symbolize_names: true)
38
- raise res[:error] unless res[:ok]
39
-
40
- res[:value]
41
- rescue Errno::ENOENT, Errno::ECONNREFUSED
42
- warn "[MudisClient] Cannot connect to MudisServer. Is it running?"
43
- nil
44
- rescue JSON::ParserError
45
- warn "[MudisClient] Invalid JSON response from server"
46
- nil
47
- rescue IOError, SystemCallError => e
48
- warn "[MudisClient] Connection error: #{e.message}"
49
- nil
31
+ attempts = 0
32
+
33
+ begin
34
+ attempts += 1
35
+ response = nil
36
+
37
+ Timeout.timeout(MudisIPCConfig.timeout) do
38
+ sock = open_connection
39
+ sock.puts(JSON.dump(payload))
40
+ response = sock.gets
41
+ sock.close
42
+ end
43
+
44
+ return nil unless response
45
+
46
+ res = JSON.parse(response, symbolize_names: true)
47
+ raise res[:error] unless res[:ok]
48
+
49
+ res[:value]
50
+ rescue Errno::ENOENT, Errno::ECONNREFUSED, Timeout::Error
51
+ if attempts <= MudisIPCConfig.retries
52
+ retry
53
+ end
54
+
55
+ warn "[MudisClient] Cannot connect to MudisServer. Is it running?"
56
+ nil
57
+ rescue JSON::ParserError
58
+ warn "[MudisClient] Invalid JSON response from server"
59
+ nil
60
+ rescue IOError, SystemCallError => e
61
+ warn "[MudisClient] Connection error: #{e.message}"
62
+ nil
63
+ end
50
64
  end
51
65
  end
52
66
 
@@ -82,19 +96,44 @@ class MudisClient
82
96
  new_val
83
97
  end
84
98
 
85
- # Retrieve metrics from the Mudis server
86
- def metrics
87
- command("metrics")
99
+ # Inspect metadata for a key
100
+ def inspect(key, namespace: nil)
101
+ command("inspect", key:, namespace:)
102
+ end
103
+
104
+ # Return keys for a namespace
105
+ def keys(namespace:)
106
+ command("keys", namespace:)
107
+ end
108
+
109
+ # Clear keys in a namespace
110
+ def clear_namespace(namespace:)
111
+ command("clear_namespace", namespace:)
112
+ end
113
+
114
+ # Return least touched keys
115
+ def least_touched(limit = 10)
116
+ command("least_touched", limit:)
88
117
  end
89
118
 
90
- # Reset metrics on the Mudis server
91
- def reset_metrics!
92
- command("reset_metrics")
119
+ # Return all keys
120
+ def all_keys
121
+ command("all_keys")
93
122
  end
94
123
 
95
- # Reset the Mudis server cache state
96
- def reset!
97
- command("reset")
124
+ # Return current memory usage
125
+ def current_memory_bytes
126
+ command("current_memory_bytes")
127
+ end
128
+
129
+ # Return max memory configured
130
+ def max_memory_bytes
131
+ command("max_memory_bytes")
132
+ end
133
+
134
+ # Retrieve metrics from the Mudis server
135
+ def metrics
136
+ command("metrics")
98
137
  end
99
138
 
100
139
  private
@@ -103,5 +142,4 @@ class MudisClient
103
142
  def command(cmd, **opts)
104
143
  request({ cmd:, **opts })
105
144
  end
106
-
107
145
  end
data/lib/mudis_config.rb CHANGED
@@ -8,6 +8,7 @@ class MudisConfig
8
8
  :max_value_bytes,
9
9
  :hard_memory_limit,
10
10
  :max_bytes,
11
+ :eviction_threshold,
11
12
  :buckets,
12
13
  :max_ttl,
13
14
  :default_ttl,
@@ -23,6 +24,7 @@ class MudisConfig
23
24
  @max_value_bytes = nil # Max size per value (optional)
24
25
  @hard_memory_limit = false # Enforce max_bytes as hard cap
25
26
  @max_bytes = 1_073_741_824 # 1 GB default max cache size
27
+ @eviction_threshold = 0.9 # Evict when bucket exceeds threshold
26
28
  @buckets = nil # use nil to signal fallback to ENV or default
27
29
  @max_ttl = nil # Max TTL for cache entries (optional)
28
30
  @default_ttl = nil # Default TTL for cache entries (optional)
@@ -5,9 +5,19 @@ module MudisIPCConfig
5
5
  SOCKET_PATH = "/tmp/mudis.sock"
6
6
  TCP_HOST = "127.0.0.1"
7
7
  TCP_PORT = 9876
8
+ DEFAULT_TIMEOUT = 1
9
+ DEFAULT_RETRIES = 1
8
10
 
9
11
  # Check if TCP mode should be used (Windows or forced via ENV)
10
12
  def self.use_tcp?
11
13
  ENV["MUDIS_FORCE_TCP"] == "true" || Gem.win_platform?
12
14
  end
15
+
16
+ def self.timeout
17
+ (ENV["MUDIS_IPC_TIMEOUT"] || DEFAULT_TIMEOUT).to_f
18
+ end
19
+
20
+ def self.retries
21
+ (ENV["MUDIS_IPC_RETRIES"] || DEFAULT_RETRIES).to_i
22
+ end
13
23
  end
data/lib/mudis_server.rb CHANGED
@@ -13,14 +13,19 @@ class MudisServer
13
13
  # Define command handlers mapping
14
14
  # Each command maps to a lambda that takes a request hash and performs the corresponding Mudis operation.
15
15
  COMMANDS = {
16
- "read" => ->(r) { Mudis.read(r[:key], namespace: r[:namespace]) },
17
- "write" => ->(r) { Mudis.write(r[:key], r[:value], expires_in: r[:ttl], namespace: r[:namespace]) },
18
- "delete" => ->(r) { Mudis.delete(r[:key], namespace: r[:namespace]) },
19
- "exists" => ->(r) { Mudis.exists?(r[:key], namespace: r[:namespace]) },
20
- "fetch" => ->(r) { Mudis.fetch(r[:key], expires_in: r[:ttl], namespace: r[:namespace]) { r[:fallback] } },
21
- "metrics" => ->(_) { Mudis.metrics },
22
- "reset_metrics" => ->(_) { Mudis.reset_metrics! },
23
- "reset" => ->(_) { Mudis.reset! }
16
+ "read" => ->(r) { Mudis.read(r[:key], namespace: r[:namespace]) },
17
+ "write" => ->(r) { Mudis.write(r[:key], r[:value], expires_in: r[:ttl], namespace: r[:namespace]) },
18
+ "delete" => ->(r) { Mudis.delete(r[:key], namespace: r[:namespace]) },
19
+ "exists" => ->(r) { Mudis.exists?(r[:key], namespace: r[:namespace]) },
20
+ "fetch" => ->(r) { Mudis.fetch(r[:key], expires_in: r[:ttl], namespace: r[:namespace]) { r[:fallback] } },
21
+ "inspect" => ->(r) { Mudis.inspect(r[:key], namespace: r[:namespace]) },
22
+ "keys" => ->(r) { Mudis.keys(namespace: r[:namespace]) },
23
+ "clear_namespace" => ->(r) { Mudis.clear_namespace(namespace: r[:namespace]) },
24
+ "least_touched" => ->(r) { Mudis.least_touched(r[:limit]) },
25
+ "all_keys" => ->(_) { Mudis.all_keys },
26
+ "current_memory_bytes" => ->(_) { Mudis.current_memory_bytes },
27
+ "max_memory_bytes" => ->(_) { Mudis.max_memory_bytes },
28
+ "metrics" => ->(_) { Mudis.metrics }
24
29
  }.freeze
25
30
 
26
31
  # Start the MudisServer
@@ -43,7 +48,7 @@ class MudisServer
43
48
  end
44
49
 
45
50
  # Start UNIX socket server (production mode for Linux/macOS)
46
- def self.start_unix_server! # rubocop:disable Metrics/MethodLength
51
+ def self.start_unix_server!
47
52
  File.unlink(SOCKET_PATH) if File.exist?(SOCKET_PATH)
48
53
  server = UNIXServer.new(SOCKET_PATH)
49
54
  server.listen(128)
data/sig/mudis.rbs CHANGED
@@ -14,6 +14,7 @@ class Mudis
14
14
  attr_reader max_value_bytes : Integer?
15
15
  attr_accessor max_ttl: Integer?
16
16
  attr_accessor default_ttl: Integer?
17
+ attr_accessor eviction_threshold: Float?
17
18
 
18
19
  def configure: () { (config: MudisConfig) -> void } -> void
19
20
  def config: () -> MudisConfig
@@ -21,6 +22,13 @@ class Mudis
21
22
  def validate_config!: () -> void
22
23
 
23
24
  def buckets: () -> Integer
25
+
26
+ def bind: (
27
+ namespace: String,
28
+ ?default_ttl: Integer?,
29
+ ?max_ttl: Integer?,
30
+ ?max_value_bytes: Integer?
31
+ ) -> Mudis::Bound
24
32
  end
25
33
 
26
34
  # Lifecycle
@@ -39,7 +47,8 @@ class Mudis
39
47
  String,
40
48
  ?expires_in: Integer,
41
49
  ?force: bool,
42
- ?namespace: String
50
+ ?namespace: String,
51
+ ?singleflight: bool
43
52
  ) { () -> untyped } -> untyped
44
53
 
45
54
  def self.clear: (String, ?namespace: String) -> void
@@ -50,7 +59,7 @@ class Mudis
50
59
  def self.with_namespace: (namespace: String) { () -> untyped } -> untyped
51
60
 
52
61
  # Introspection & management
53
- def self.metrics: () -> Hash[Symbol, untyped]
62
+ def self.metrics: (?namespace: String) -> Hash[Symbol, untyped]
54
63
  def self.cleanup_expired!: () -> void
55
64
  def self.all_keys: () -> Array[String]
56
65
  def self.current_memory_bytes: () -> Integer
@@ -0,0 +1,25 @@
1
+ class Mudis
2
+ class Bound
3
+ attr_reader namespace: String
4
+
5
+ def initialize: (
6
+ namespace: String,
7
+ ?default_ttl: Integer?,
8
+ ?max_ttl: Integer?,
9
+ ?max_value_bytes: Integer?
10
+ ) -> void
11
+
12
+ def read: (String) -> untyped?
13
+ def write: (String, untyped, ?expires_in: Integer?) -> void
14
+ def update: (String) { (untyped) -> untyped } -> void
15
+ def delete: (String) -> void
16
+ def exists?: (String) -> bool
17
+ def fetch: (String, ?expires_in: Integer?, ?force: bool, ?singleflight: bool) { () -> untyped } -> untyped?
18
+ def clear: (String) -> void
19
+ def replace: (String, untyped, ?expires_in: Integer?) -> void
20
+ def inspect: (String) -> Hash[Symbol, untyped]?
21
+ def keys: () -> Array[String]
22
+ def metrics: () -> Hash[Symbol, untyped]
23
+ def clear_namespace: () -> void
24
+ end
25
+ end
data/sig/mudis_client.rbs CHANGED
@@ -17,9 +17,19 @@ class MudisClient
17
17
 
18
18
  def fetch: (key: String, expires_in?: Integer?, namespace?: String?, &block: { () -> untyped }) -> untyped
19
19
 
20
- def metrics: () -> Hash[Symbol, untyped]
20
+ def inspect: (key: String, namespace?: String?) -> Hash[Symbol, untyped]?
21
+
22
+ def keys: (namespace: String) -> Array[String]
23
+
24
+ def clear_namespace: (namespace: String) -> void
25
+
26
+ def least_touched: (?Integer) -> Array[[String, Integer]]
21
27
 
22
- def reset_metrics!: () -> void
28
+ def all_keys: () -> Array[String]
23
29
 
24
- def reset!: () -> void
30
+ def current_memory_bytes: () -> Integer
31
+
32
+ def max_memory_bytes: () -> Integer
33
+
34
+ def metrics: () -> Hash[Symbol, untyped]
25
35
  end
data/sig/mudis_config.rbs CHANGED
@@ -4,6 +4,7 @@ class MudisConfig
4
4
  attr_accessor max_value_bytes: Integer?
5
5
  attr_accessor hard_memory_limit: bool
6
6
  attr_accessor max_bytes: Integer
7
+ attr_accessor eviction_threshold: Float?
7
8
  attr_accessor max_ttl: Integer?
8
9
  attr_accessor default_ttl: Integer?
9
10
  attr_accessor buckets: Integer?
@@ -6,5 +6,13 @@ module MudisIPCConfig
6
6
 
7
7
  TCP_PORT: Integer
8
8
 
9
+ DEFAULT_TIMEOUT: Integer
10
+
11
+ DEFAULT_RETRIES: Integer
12
+
9
13
  def self.use_tcp?: () -> bool
14
+
15
+ def self.timeout: () -> Float
16
+
17
+ def self.retries: () -> Integer
10
18
  end
@@ -1,6 +1,6 @@
1
1
  class Mudis
2
2
  module Metrics
3
- def metrics: () -> Hash[Symbol, untyped]
3
+ def metrics: (?namespace: String) -> Hash[Symbol, untyped]
4
4
 
5
5
  def reset_metrics!: () -> void
6
6
 
@@ -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
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