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.
- checksums.yaml +4 -4
- data/README.md +11 -5
- data/lib/mudis/expiry.rb +53 -0
- data/lib/mudis/lru.rb +66 -0
- data/lib/mudis/metrics.rb +42 -0
- data/lib/mudis/namespace.rb +47 -0
- data/lib/mudis/persistence.rb +113 -0
- data/lib/mudis/version.rb +1 -1
- data/lib/mudis.rb +15 -285
- data/lib/mudis_client.rb +63 -21
- data/lib/mudis_ipc_config.rb +13 -0
- data/lib/mudis_proxy.rb +2 -0
- data/lib/mudis_server.rb +73 -54
- data/sig/mudis.rbs +6 -0
- data/sig/mudis_client.rbs +3 -1
- data/sig/mudis_expiry.rbs +13 -0
- data/sig/mudis_ipc_config.rbs +10 -0
- data/sig/mudis_lru.rbs +21 -0
- data/sig/mudis_metrics.rbs +11 -0
- data/sig/mudis_namespace.rbs +13 -0
- data/sig/mudis_persistence.rbs +19 -0
- data/sig/mudis_server.rbs +12 -2
- data/spec/api_compatibility_spec.rb +155 -0
- data/spec/modules/expiry_spec.rb +170 -0
- data/spec/modules/lru_spec.rb +149 -0
- data/spec/modules/metrics_spec.rb +105 -0
- data/spec/modules/namespace_spec.rb +157 -0
- data/spec/modules/persistence_spec.rb +125 -0
- data/spec/mudis_client_spec.rb +15 -5
- data/spec/mudis_server_spec.rb +23 -17
- metadata +46 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 283e3b629449e6324d30dd19450d4d363a5d415ad45ace27569d4d82fb7e8066
|
|
4
|
+
data.tar.gz: 5fe63b61e7526eb90c66c9a5f9da721c5156ec40a144398cedb89e11adc7b35e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cfc3db7ea2a80a03af39738a6700bbd07b525dbd101bf7b3b8f11b1fa711fdc14a9ad248dcdeff5b469e55bca24efd15d08ac5bf3ff91c83b6614c5ddc925521
|
|
7
|
+
data.tar.gz: 4923b6e5ccb935914e0c5811be65fbe358304db5f6e8d76097b08f25db939e441b0b72557695981ffdae7e300d36dd4a66c3d2b3d2fd163c1e3735dbddbd61e6
|
data/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://www.jetbrains.com/ruby/)
|
|
4
4
|
|
|
5
5
|
[](https://badge.fury.io/rb/mudis)
|
|
6
|
+
[](https://www.rubydoc.info/gems/mudis)
|
|
6
7
|
[](LICENSE)
|
|
7
8
|
|
|
8
9
|
**Mudis** is a fast, thread-safe, in-memory, sharded LRU (Least Recently Used) cache for Ruby applications. Inspired by Redis, it provides value serialization, optional compression, per-key expiry, and metric tracking in a lightweight, dependency-free package that lives inside your Ruby process.
|
|
@@ -26,7 +27,7 @@ Mudis also works naturally in Hanami because it’s a pure Ruby in-memory cache.
|
|
|
26
27
|
- [Cache Key Lifecycle](#cache-key-lifecycle)
|
|
27
28
|
- [Features](#features)
|
|
28
29
|
- [Installation](#installation)
|
|
29
|
-
- [Configuration (Rails)](#configuration-
|
|
30
|
+
- [Configuration (Ruby/Rails)](#configuration-rubyrails)
|
|
30
31
|
- [Configuration (Hanami)](#configuration-hanami)
|
|
31
32
|
- [Start and Stop Exipry Thread](#start-and-stop-exipry-thread)
|
|
32
33
|
- [Starting Exipry Thread](#starting-exipry-thread)
|
|
@@ -133,15 +134,13 @@ Or install it manually:
|
|
|
133
134
|
```bash
|
|
134
135
|
gem install mudis
|
|
135
136
|
```
|
|
136
|
-
|
|
137
137
|
---
|
|
138
138
|
|
|
139
|
-
## Configuration (Rails)
|
|
139
|
+
## Configuration (Ruby/Rails)
|
|
140
140
|
|
|
141
|
-
In your Rails app, create an initializer:
|
|
141
|
+
In your Rails app, create an initializer (in your entry point if using Ruby outside of Rails):
|
|
142
142
|
|
|
143
143
|
```ruby
|
|
144
|
-
# config/initializers/mudis.rb
|
|
145
144
|
Mudis.configure do |c|
|
|
146
145
|
c.serializer = JSON # or Marshal | Oj
|
|
147
146
|
c.compress = true # Compress values using Zlib
|
|
@@ -276,9 +275,15 @@ require 'mudis'
|
|
|
276
275
|
# Write a value with optional TTL
|
|
277
276
|
Mudis.write('user:123', { name: 'Alice' }, expires_in: 600)
|
|
278
277
|
|
|
278
|
+
# Write a value with explicit namespace separation
|
|
279
|
+
Mudis.write('123', { name: 'Alice' }, expires_in: 600, namespace:'user')
|
|
280
|
+
|
|
279
281
|
# Read it back
|
|
280
282
|
Mudis.read('user:123') # => { "name" => "Alice" }
|
|
281
283
|
|
|
284
|
+
# Read back from namespace
|
|
285
|
+
Mudis.read('123', namespace:'user') # => { "name" => "Alice" }
|
|
286
|
+
|
|
282
287
|
# Check if it exists
|
|
283
288
|
Mudis.exists?('user:123') # => true
|
|
284
289
|
|
|
@@ -929,6 +934,7 @@ Mudis is not intended to be a general-purpose, distributed caching platform. You
|
|
|
929
934
|
|
|
930
935
|
- [x] Review Mudis for improved readability and reduce complexity in top-level functions
|
|
931
936
|
- [x] Enhanced guards
|
|
937
|
+
- [ ] Refactor main classes for enhanced readability
|
|
932
938
|
- [ ] Review for functionality gaps and enhance as needed
|
|
933
939
|
|
|
934
940
|
---
|
data/lib/mudis/expiry.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Mudis
|
|
4
|
+
# Expiry module handles TTL-based expiration and background cleanup
|
|
5
|
+
module Expiry
|
|
6
|
+
# Starts a thread that periodically removes expired entries
|
|
7
|
+
def start_expiry_thread(interval: 60)
|
|
8
|
+
return if @expiry_thread&.alive?
|
|
9
|
+
|
|
10
|
+
@stop_expiry = false
|
|
11
|
+
@expiry_thread = Thread.new do
|
|
12
|
+
loop do
|
|
13
|
+
break if @stop_expiry
|
|
14
|
+
|
|
15
|
+
sleep interval
|
|
16
|
+
cleanup_expired!
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Signals and joins the expiry thread
|
|
22
|
+
def stop_expiry_thread
|
|
23
|
+
@stop_expiry = true
|
|
24
|
+
@expiry_thread&.join
|
|
25
|
+
@expiry_thread = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Removes expired keys across all buckets
|
|
29
|
+
def cleanup_expired!
|
|
30
|
+
now = Time.now
|
|
31
|
+
buckets.times do |idx|
|
|
32
|
+
mutex = @mutexes[idx]
|
|
33
|
+
store = @stores[idx]
|
|
34
|
+
mutex.synchronize do
|
|
35
|
+
store.keys.each do |key| # rubocop:disable Style/HashEachMethods
|
|
36
|
+
evict_key(idx, key) if store[key][:expires_at] && now > store[key][:expires_at]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Calculates the effective TTL for an entry, respecting max_ttl if set
|
|
45
|
+
def effective_ttl(expires_in)
|
|
46
|
+
ttl = expires_in || @default_ttl
|
|
47
|
+
return nil unless ttl
|
|
48
|
+
return ttl unless @max_ttl
|
|
49
|
+
|
|
50
|
+
[ttl, @max_ttl].min
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/mudis/lru.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Mudis
|
|
4
|
+
# Node structure for the LRU doubly-linked list
|
|
5
|
+
class LRUNode
|
|
6
|
+
attr_accessor :key, :prev, :next
|
|
7
|
+
|
|
8
|
+
def initialize(key)
|
|
9
|
+
@key = key
|
|
10
|
+
@prev = nil
|
|
11
|
+
@next = nil
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# LRU module handles the Least Recently Used eviction strategy
|
|
16
|
+
# Maintains doubly-linked lists per bucket for O(1) promote/evict operations
|
|
17
|
+
module LRU
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# Removes a key from storage and LRU
|
|
21
|
+
def evict_key(idx, key)
|
|
22
|
+
store = @stores[idx]
|
|
23
|
+
entry = store.delete(key)
|
|
24
|
+
return unless entry
|
|
25
|
+
|
|
26
|
+
@current_bytes[idx] -= (key.bytesize + entry[:value].bytesize)
|
|
27
|
+
|
|
28
|
+
node = @lru_nodes[idx].delete(key)
|
|
29
|
+
remove_node(idx, node) if node
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Inserts a key at the head of the LRU list
|
|
33
|
+
def insert_lru(idx, key)
|
|
34
|
+
node = LRUNode.new(key)
|
|
35
|
+
node.next = @lru_heads[idx]
|
|
36
|
+
@lru_heads[idx].prev = node if @lru_heads[idx]
|
|
37
|
+
@lru_heads[idx] = node
|
|
38
|
+
@lru_tails[idx] ||= node
|
|
39
|
+
@lru_nodes[idx][key] = node
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Promotes a key to the front of the LRU list
|
|
43
|
+
def promote_lru(idx, key)
|
|
44
|
+
node = @lru_nodes[idx][key]
|
|
45
|
+
return unless node && @lru_heads[idx] != node
|
|
46
|
+
|
|
47
|
+
remove_node(idx, node)
|
|
48
|
+
insert_lru(idx, key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Removes a node from the LRU list
|
|
52
|
+
def remove_node(idx, node)
|
|
53
|
+
if node.prev
|
|
54
|
+
node.prev.next = node.next
|
|
55
|
+
else
|
|
56
|
+
@lru_heads[idx] = node.next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if node.next
|
|
60
|
+
node.next.prev = node.prev
|
|
61
|
+
else
|
|
62
|
+
@lru_tails[idx] = node.prev
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Mudis
|
|
4
|
+
# Metrics module handles tracking of cache hits, misses, evictions and memory usage
|
|
5
|
+
module Metrics
|
|
6
|
+
# Returns a snapshot of metrics (thread-safe)
|
|
7
|
+
def metrics # rubocop:disable Metrics/MethodLength
|
|
8
|
+
@metrics_mutex.synchronize do
|
|
9
|
+
{
|
|
10
|
+
hits: @metrics[:hits],
|
|
11
|
+
misses: @metrics[:misses],
|
|
12
|
+
evictions: @metrics[:evictions],
|
|
13
|
+
rejected: @metrics[:rejected],
|
|
14
|
+
total_memory: current_memory_bytes,
|
|
15
|
+
least_touched: least_touched(10),
|
|
16
|
+
buckets: buckets.times.map do |idx|
|
|
17
|
+
{
|
|
18
|
+
index: idx,
|
|
19
|
+
keys: @stores[idx].size,
|
|
20
|
+
memory_bytes: @current_bytes[idx],
|
|
21
|
+
lru_size: @lru_nodes[idx].size
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Resets metric counters (thread-safe)
|
|
29
|
+
def reset_metrics!
|
|
30
|
+
@metrics_mutex.synchronize do
|
|
31
|
+
@metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Thread-safe metric increment
|
|
38
|
+
def metric(name)
|
|
39
|
+
@metrics_mutex.synchronize { @metrics[name] += 1 }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Mudis
|
|
4
|
+
# Namespace module handles logical key separation and scoping
|
|
5
|
+
module Namespace
|
|
6
|
+
# Returns all keys in a specific namespace
|
|
7
|
+
def keys(namespace:)
|
|
8
|
+
raise ArgumentError, "namespace is required" unless namespace
|
|
9
|
+
|
|
10
|
+
prefix = "#{namespace}:"
|
|
11
|
+
all_keys.select { |key| key.start_with?(prefix) }.map { |key| key.delete_prefix(prefix) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Clears all keys in a specific namespace
|
|
15
|
+
def clear_namespace(namespace:)
|
|
16
|
+
raise ArgumentError, "namespace is required" unless namespace
|
|
17
|
+
|
|
18
|
+
prefix = "#{namespace}:"
|
|
19
|
+
buckets.times do |idx|
|
|
20
|
+
mutex = @mutexes[idx]
|
|
21
|
+
store = @stores[idx]
|
|
22
|
+
|
|
23
|
+
mutex.synchronize do
|
|
24
|
+
keys_to_delete = store.keys.select { |key| key.start_with?(prefix) }
|
|
25
|
+
keys_to_delete.each { |key| evict_key(idx, key) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Executes a block with a specific namespace, restoring the old namespace afterwards
|
|
31
|
+
def with_namespace(namespace)
|
|
32
|
+
old_ns = Thread.current[:mudis_namespace]
|
|
33
|
+
Thread.current[:mudis_namespace] = namespace
|
|
34
|
+
yield
|
|
35
|
+
ensure
|
|
36
|
+
Thread.current[:mudis_namespace] = old_ns
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Namespaces a key with an optional namespace
|
|
42
|
+
def namespaced_key(key, namespace = nil)
|
|
43
|
+
ns = namespace || Thread.current[:mudis_namespace]
|
|
44
|
+
ns ? "#{ns}:#{key}" : key
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Mudis
|
|
4
|
+
# Persistence module handles snapshot save/load operations for warm boot support
|
|
5
|
+
module Persistence
|
|
6
|
+
# Saves the current cache state to disk for persistence
|
|
7
|
+
def save_snapshot!
|
|
8
|
+
return unless @persistence_enabled
|
|
9
|
+
|
|
10
|
+
data = snapshot_dump
|
|
11
|
+
safe_write_snapshot(data)
|
|
12
|
+
rescue StandardError => e
|
|
13
|
+
warn "[Mudis] Failed to save snapshot: #{e.class}: #{e.message}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Loads the cache state from disk for persistence
|
|
17
|
+
def load_snapshot!
|
|
18
|
+
return unless @persistence_enabled
|
|
19
|
+
return unless File.exist?(@persistence_path)
|
|
20
|
+
|
|
21
|
+
data = read_snapshot
|
|
22
|
+
snapshot_restore(data)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
warn "[Mudis] Failed to load snapshot: #{e.class}: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Installs an at_exit hook to save the snapshot on process exit
|
|
28
|
+
def install_persistence_hook!
|
|
29
|
+
return unless @persistence_enabled
|
|
30
|
+
return if defined?(@persistence_hook_installed) && @persistence_hook_installed
|
|
31
|
+
|
|
32
|
+
at_exit { save_snapshot! }
|
|
33
|
+
@persistence_hook_installed = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Collect a JSON/Marshal-safe array of { key, value, expires_in }
|
|
39
|
+
def snapshot_dump # rubocop:disable Metrics/MethodLength
|
|
40
|
+
entries = []
|
|
41
|
+
now = Time.now
|
|
42
|
+
@buckets.times do |idx|
|
|
43
|
+
mutex = @mutexes[idx]
|
|
44
|
+
store = @stores[idx]
|
|
45
|
+
mutex.synchronize do
|
|
46
|
+
store.each do |key, raw|
|
|
47
|
+
exp_at = raw[:expires_at]
|
|
48
|
+
next if exp_at && now > exp_at
|
|
49
|
+
|
|
50
|
+
value = decompress_and_deserialize(raw[:value])
|
|
51
|
+
expires_in = exp_at ? (exp_at - now).to_i : nil
|
|
52
|
+
entries << { key: key, value: value, expires_in: expires_in }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
entries
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Restore via existing write-path so LRU/limits/compression/TTL are honored
|
|
60
|
+
def snapshot_restore(entries)
|
|
61
|
+
return unless entries && !entries.empty?
|
|
62
|
+
|
|
63
|
+
entries.each do |e|
|
|
64
|
+
begin # rubocop:disable Style/RedundantBegin
|
|
65
|
+
write(e[:key], e[:value], expires_in: e[:expires_in])
|
|
66
|
+
rescue StandardError => ex
|
|
67
|
+
warn "[Mudis] Failed to restore key #{e[:key].inspect}: #{ex.message}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Serializer for snapshot persistence
|
|
73
|
+
# Defaults to Marshal if not JSON
|
|
74
|
+
def serializer_for_snapshot
|
|
75
|
+
(@persistence_format || :marshal).to_sym == :json ? JSON : :marshal
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Safely writes snapshot data to disk
|
|
79
|
+
# Uses safe write if configured
|
|
80
|
+
def safe_write_snapshot(data) # rubocop:disable Metrics/MethodLength
|
|
81
|
+
path = @persistence_path
|
|
82
|
+
dir = File.dirname(path)
|
|
83
|
+
Dir.mkdir(dir) unless Dir.exist?(dir)
|
|
84
|
+
|
|
85
|
+
payload =
|
|
86
|
+
if (@persistence_format || :marshal).to_sym == :json
|
|
87
|
+
serializer_for_snapshot.dump(data)
|
|
88
|
+
else
|
|
89
|
+
Marshal.dump(data)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if @persistence_safe_write
|
|
93
|
+
tmp = "#{path}.tmp-#{$$}-#{Thread.current.object_id}"
|
|
94
|
+
File.open(tmp, "wb") { |f| f.write(payload) }
|
|
95
|
+
File.rename(tmp, path)
|
|
96
|
+
else
|
|
97
|
+
File.open(path, "wb") { |f| f.write(payload) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Reads snapshot data from disk
|
|
102
|
+
# Uses safe read if configured
|
|
103
|
+
def read_snapshot
|
|
104
|
+
if (@persistence_format || :marshal).to_sym == :json
|
|
105
|
+
# Use JSON.parse instead of JSON.load to support symbolize_names option
|
|
106
|
+
serializer_for_snapshot.parse(File.binread(@persistence_path), symbolize_names: true)
|
|
107
|
+
else
|
|
108
|
+
## safe to use Marshal here as we control the file
|
|
109
|
+
Marshal.load(File.binread(@persistence_path)) # rubocop:disable Security/MarshalLoad
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/mudis/version.rb
CHANGED