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.
- checksums.yaml +4 -4
- data/README.md +41 -5
- data/lib/mudis/bound.rb +128 -0
- data/lib/mudis/metrics.rb +21 -2
- data/lib/mudis/version.rb +1 -1
- data/lib/mudis.rb +99 -23
- data/lib/mudis_client.rb +69 -31
- data/lib/mudis_config.rb +2 -0
- data/lib/mudis_ipc_config.rb +10 -0
- data/lib/mudis_server.rb +14 -9
- data/sig/mudis.rbs +11 -2
- data/sig/mudis_bound.rbs +25 -0
- data/sig/mudis_client.rbs +13 -3
- data/sig/mudis_config.rbs +1 -0
- data/sig/mudis_ipc_config.rbs +8 -0
- data/sig/mudis_metrics.rbs +1 -1
- data/spec/api_compatibility_spec.rb +3 -0
- data/spec/bound_spec.rb +89 -0
- data/spec/guardrails_spec.rb +14 -0
- data/spec/metrics_spec.rb +21 -0
- data/spec/modules/metrics_spec.rb +4 -1
- data/spec/modules/persistence_spec.rb +24 -26
- data/spec/mudis_client_spec.rb +83 -9
- data/spec/mudis_server_spec.rb +51 -17
- data/spec/mudis_spec.rb +37 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 987b789b060c3e66ed1ce29fc8fc868f9955e986349a48da34f2d06f29bf21f4
|
|
4
|
+
data.tar.gz: cb846d56202f3934cb1f4e47ede9d12ef3cd8af050cedd6e5f901945b95c41ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c14266d9bf5121b085f79029678ca4bf429451adf9335710d2046d152a9a17618051d141e2091fc6a260d5bcf9ec522680462037f9576d2d2504d09ddc8418e2
|
|
7
|
+
data.tar.gz: 217d6a415f9f7b9bea4240d6300337e4ea86e7aa10308a92dfb8c673ad8410f13cefb77c424bd00c7b2b138c698aefec030d47709fa78bd0b87dcb1e797874e7
|
data/README.md
CHANGED
|
@@ -157,9 +157,10 @@ sequenceDiagram
|
|
|
157
157
|
- **LRU Eviction**: Automatically evicts least recently used items as memory fills up.
|
|
158
158
|
- **Expiry Support**: Optional TTL per key with background cleanup thread.
|
|
159
159
|
- **Compression**: Optional Zlib compression for large values.
|
|
160
|
-
- **Metrics**: Tracks hits, misses, and evictions.
|
|
160
|
+
- **Metrics**: Tracks hits, misses, and evictions, with optional per-namespace counters.
|
|
161
161
|
- **IPC Mode**: Shared cross-process caching for multi-process aplications.
|
|
162
162
|
- **Soft-persistence**: Data snapshot and reload.
|
|
163
|
+
- **Caller Binding**: Optional scoped cache wrappers via `Mudis.bind`.
|
|
163
164
|
|
|
164
165
|
---
|
|
165
166
|
|
|
@@ -189,6 +190,7 @@ Mudis.configure do |c|
|
|
|
189
190
|
c.max_value_bytes = 2_000_000 # Reject values > 2MB
|
|
190
191
|
c.hard_memory_limit = true # enforce hard memory limits
|
|
191
192
|
c.max_bytes = 1_073_741_824 # set maximum cache size
|
|
193
|
+
c.eviction_threshold = 0.9 # evict when a bucket exceeds 90% of max_bytes
|
|
192
194
|
end
|
|
193
195
|
|
|
194
196
|
Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
|
|
@@ -206,6 +208,7 @@ Mudis.compress = true # Compress values using Zlib
|
|
|
206
208
|
Mudis.max_value_bytes = 2_000_000 # Reject values > 2MB
|
|
207
209
|
Mudis.hard_memory_limit = true # enforce hard memory limits
|
|
208
210
|
Mudis.max_bytes = 1_073_741_824 # set maximum cache size
|
|
211
|
+
Mudis.eviction_threshold = 0.9 # evict when a bucket exceeds 90% of max_bytes
|
|
209
212
|
|
|
210
213
|
Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
|
|
211
214
|
|
|
@@ -331,11 +334,31 @@ Mudis.exists?('user:123') # => true
|
|
|
331
334
|
|
|
332
335
|
# Atomically update
|
|
333
336
|
Mudis.update('user:123') { |data| data.merge(age: 30) }
|
|
337
|
+
# Note: update refreshes TTL based on the original TTL duration.
|
|
334
338
|
|
|
335
339
|
# Delete a key
|
|
336
340
|
Mudis.delete('user:123')
|
|
337
341
|
```
|
|
338
342
|
|
|
343
|
+
### Caller-Scoped Cache (Bound)
|
|
344
|
+
|
|
345
|
+
For caller-bound caching (per-tenant or per-service scoping), use `Mudis.bind` to create a scoped wrapper:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
cache = Mudis.bind(
|
|
349
|
+
namespace: "caller:123",
|
|
350
|
+
default_ttl: 60,
|
|
351
|
+
max_ttl: 300,
|
|
352
|
+
max_value_bytes: 128_000
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
cache.write("profile", { name: "Alice" })
|
|
356
|
+
cache.read("profile") # => { "name" => "Alice" }
|
|
357
|
+
cache.fetch("expensive", singleflight: true) { expensive_query }
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
The wrapper delegates to Mudis but automatically applies the namespace and optional per-caller guardrails.
|
|
361
|
+
|
|
339
362
|
### Developer Utilities
|
|
340
363
|
|
|
341
364
|
Mudis provides utility methods to help with test environments, console debugging, and dev tool resets.
|
|
@@ -461,7 +484,7 @@ class MudisService
|
|
|
461
484
|
# @param force [Boolean] force recomputation
|
|
462
485
|
# @yield return value if key is missing
|
|
463
486
|
def fetch(expires_in: nil, force: false)
|
|
464
|
-
Mudis.fetch(cache_key, expires_in: expires_in, force: force, namespace: namespace) do
|
|
487
|
+
Mudis.fetch(cache_key, expires_in: expires_in, force: force, namespace: namespace, singleflight: true) do
|
|
465
488
|
yield
|
|
466
489
|
end
|
|
467
490
|
end
|
|
@@ -519,6 +542,13 @@ Mudis.metrics
|
|
|
519
542
|
|
|
520
543
|
```
|
|
521
544
|
|
|
545
|
+
You can also query metrics for a specific namespace:
|
|
546
|
+
|
|
547
|
+
```ruby
|
|
548
|
+
Mudis.metrics(namespace: "users")
|
|
549
|
+
# => { hits: 10, misses: 2, evictions: 1, rejected: 0, namespace: "users" }
|
|
550
|
+
```
|
|
551
|
+
|
|
522
552
|
Optionally, expose Mudis metrics from a controller or action for remote analysis and monitoring.
|
|
523
553
|
|
|
524
554
|
**Rails:**
|
|
@@ -588,6 +618,7 @@ end
|
|
|
588
618
|
| `Mudis.start_expiry_thread` | Background TTL cleanup loop (every N sec) | Disabled by default|
|
|
589
619
|
| `Mudis.hard_memory_limit` | Enforce hard memory limits on key size and reject if exceeded | `false`|
|
|
590
620
|
| `Mudis.max_bytes` | Maximum allowed cache size | `1GB`|
|
|
621
|
+
| `Mudis.eviction_threshold` | Evict when a bucket exceeds this ratio of max_bytes | `0.9` |
|
|
591
622
|
| `Mudis.max_ttl` | Set the maximum permitted TTL | `nil` (no limit) |
|
|
592
623
|
| `Mudis.default_ttl` | Set the default TTL for fallback when none is provided | `nil` |
|
|
593
624
|
|
|
@@ -695,7 +726,7 @@ This design allows multiple workers to share the same cache without duplicating
|
|
|
695
726
|
| **Shared Cache Across Processes** | All Puma workers share one Mudis instance via IPC. |
|
|
696
727
|
| **Zero External Dependencies** | No Redis, Memcached, or separate daemon required. |
|
|
697
728
|
| **Memory Efficient** | Cache data stored only once, not duplicated per worker. |
|
|
698
|
-
| **
|
|
729
|
+
| **Feature Coverage** | Core cache ops + introspection (keys, inspect, least_touched) and metrics are supported. |
|
|
699
730
|
| **Safe & Local** | Communication is limited to the host system’s UNIX socket, ensuring isolation and speed. |
|
|
700
731
|
|
|
701
732
|
```mermaid
|
|
@@ -763,13 +794,18 @@ if defined?($mudis) && $mudis
|
|
|
763
794
|
def self.delete(*a, **k) = $mudis.delete(*a, **k)
|
|
764
795
|
def self.fetch(*a, **k, &b) = $mudis.fetch(*a, **k, &b)
|
|
765
796
|
def self.metrics = $mudis.metrics
|
|
766
|
-
def self.reset_metrics! = $mudis.reset_metrics!
|
|
767
|
-
def self.reset! = $mudis.reset!
|
|
768
797
|
end
|
|
769
798
|
|
|
770
799
|
end
|
|
771
800
|
```
|
|
772
801
|
|
|
802
|
+
**IPC safety limitations:**
|
|
803
|
+
|
|
804
|
+
- Administrative operations are **not** exposed over IPC (e.g., `reset!`, `reset_metrics!`, `save_snapshot!`, `load_snapshot!`).
|
|
805
|
+
- IPC client requests use timeouts and retries:
|
|
806
|
+
- `MUDIS_IPC_TIMEOUT` (seconds, default: `1`)
|
|
807
|
+
- `MUDIS_IPC_RETRIES` (default: `1`)
|
|
808
|
+
|
|
773
809
|
**Use IPC mode when:**
|
|
774
810
|
|
|
775
811
|
- Running Rails or Rack apps under Puma cluster or multi-process background job workers.
|
data/lib/mudis/bound.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
class Mudis
|
|
6
|
+
# Scoped wrapper for caller-bound access with optional per-caller policy.
|
|
7
|
+
class Bound
|
|
8
|
+
def initialize(namespace:, default_ttl: nil, max_ttl: nil, max_value_bytes: nil)
|
|
9
|
+
raise ArgumentError, "namespace is required" if namespace.nil? || namespace.to_s.empty?
|
|
10
|
+
|
|
11
|
+
@namespace = namespace
|
|
12
|
+
@default_ttl = default_ttl
|
|
13
|
+
@max_ttl = max_ttl
|
|
14
|
+
@max_value_bytes = max_value_bytes
|
|
15
|
+
@inflight_mutexes_lock = Mutex.new
|
|
16
|
+
@inflight_mutexes = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :namespace
|
|
20
|
+
|
|
21
|
+
def read(key)
|
|
22
|
+
Mudis.read(key, namespace: @namespace)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def write(key, value, expires_in: nil)
|
|
26
|
+
return if exceeds_max_value_bytes?(value)
|
|
27
|
+
|
|
28
|
+
Mudis.write(key, value, expires_in: effective_ttl(expires_in), namespace: @namespace)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def update(key)
|
|
32
|
+
Mudis.update(key, namespace: @namespace) do |current|
|
|
33
|
+
next_value = yield(current)
|
|
34
|
+
exceeds_max_value_bytes?(next_value) ? current : next_value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def delete(key)
|
|
39
|
+
Mudis.delete(key, namespace: @namespace)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def exists?(key)
|
|
43
|
+
Mudis.exists?(key, namespace: @namespace)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fetch(key, expires_in: nil, force: false, singleflight: false)
|
|
47
|
+
return fetch_without_lock(key, expires_in:, force:) { yield } unless singleflight
|
|
48
|
+
|
|
49
|
+
with_inflight_lock(key) do
|
|
50
|
+
fetch_without_lock(key, expires_in:, force:) { yield }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def clear(key)
|
|
55
|
+
delete(key)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def replace(key, value, expires_in: nil)
|
|
59
|
+
return if exceeds_max_value_bytes?(value)
|
|
60
|
+
|
|
61
|
+
Mudis.replace(key, value, expires_in: effective_ttl(expires_in), namespace: @namespace)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inspect(key)
|
|
65
|
+
Mudis.inspect(key, namespace: @namespace)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def keys
|
|
69
|
+
Mudis.keys(namespace: @namespace)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def metrics
|
|
73
|
+
Mudis.metrics(namespace: @namespace)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def clear_namespace
|
|
77
|
+
Mudis.clear_namespace(namespace: @namespace)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def effective_ttl(expires_in)
|
|
83
|
+
ttl = expires_in || @default_ttl
|
|
84
|
+
return nil unless ttl
|
|
85
|
+
return ttl unless @max_ttl
|
|
86
|
+
|
|
87
|
+
[ttl, @max_ttl].min
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def exceeds_max_value_bytes?(value)
|
|
91
|
+
return false unless @max_value_bytes
|
|
92
|
+
|
|
93
|
+
raw = Mudis.serializer.dump(value)
|
|
94
|
+
raw = Zlib::Deflate.deflate(raw) if Mudis.compress
|
|
95
|
+
raw.bytesize > @max_value_bytes
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def fetch_without_lock(key, expires_in:, force:)
|
|
99
|
+
unless force
|
|
100
|
+
cached = read(key)
|
|
101
|
+
return cached if cached
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
value = yield
|
|
105
|
+
return nil if exceeds_max_value_bytes?(value)
|
|
106
|
+
|
|
107
|
+
write(key, value, expires_in: expires_in)
|
|
108
|
+
value
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def with_inflight_lock(lock_key)
|
|
112
|
+
entry = nil
|
|
113
|
+
@inflight_mutexes_lock.synchronize do
|
|
114
|
+
entry = (@inflight_mutexes[lock_key] ||= { mutex: Mutex.new, count: 0 })
|
|
115
|
+
entry[:count] += 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
entry[:mutex].synchronize { yield }
|
|
119
|
+
ensure
|
|
120
|
+
@inflight_mutexes_lock.synchronize do
|
|
121
|
+
next unless entry
|
|
122
|
+
|
|
123
|
+
entry[:count] -= 1
|
|
124
|
+
@inflight_mutexes.delete(lock_key) if entry[:count] <= 0
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/mudis/metrics.rb
CHANGED
|
@@ -4,7 +4,9 @@ class Mudis
|
|
|
4
4
|
# Metrics module handles tracking of cache hits, misses, evictions and memory usage
|
|
5
5
|
module Metrics
|
|
6
6
|
# Returns a snapshot of metrics (thread-safe)
|
|
7
|
-
def metrics # rubocop:disable Metrics/MethodLength
|
|
7
|
+
def metrics(namespace: nil) # rubocop:disable Metrics/MethodLength
|
|
8
|
+
return namespace_metrics(namespace) if namespace
|
|
9
|
+
|
|
8
10
|
@metrics_mutex.synchronize do
|
|
9
11
|
{
|
|
10
12
|
hits: @metrics[:hits],
|
|
@@ -30,13 +32,30 @@ class Mudis
|
|
|
30
32
|
@metrics_mutex.synchronize do
|
|
31
33
|
@metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 }
|
|
32
34
|
end
|
|
35
|
+
|
|
36
|
+
@metrics_by_namespace_mutex.synchronize do
|
|
37
|
+
@metrics_by_namespace = {}
|
|
38
|
+
end
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
private
|
|
36
42
|
|
|
37
43
|
# Thread-safe metric increment
|
|
38
|
-
def metric(name)
|
|
44
|
+
def metric(name, namespace: nil)
|
|
39
45
|
@metrics_mutex.synchronize { @metrics[name] += 1 }
|
|
46
|
+
return unless namespace
|
|
47
|
+
|
|
48
|
+
@metrics_by_namespace_mutex.synchronize do
|
|
49
|
+
@metrics_by_namespace[namespace] ||= { hits: 0, misses: 0, evictions: 0, rejected: 0 }
|
|
50
|
+
@metrics_by_namespace[namespace][name] += 1
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def namespace_metrics(namespace)
|
|
55
|
+
@metrics_by_namespace_mutex.synchronize do
|
|
56
|
+
entry = @metrics_by_namespace[namespace] || { hits: 0, misses: 0, evictions: 0, rejected: 0 }
|
|
57
|
+
entry.merge(namespace: namespace)
|
|
58
|
+
end
|
|
40
59
|
end
|
|
41
60
|
end
|
|
42
61
|
end
|
data/lib/mudis/version.rb
CHANGED
data/lib/mudis.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "mudis/persistence"
|
|
|
10
10
|
require_relative "mudis/metrics"
|
|
11
11
|
require_relative "mudis/namespace"
|
|
12
12
|
require_relative "mudis/expiry"
|
|
13
|
+
require_relative "mudis/bound"
|
|
13
14
|
|
|
14
15
|
# Mudis is a thread-safe, in-memory, sharded, LRU cache with optional compression and expiry.
|
|
15
16
|
# It is designed for high concurrency and performance within a Ruby application.
|
|
@@ -26,6 +27,8 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
26
27
|
@compress = false # Whether to compress values with Zlib
|
|
27
28
|
@metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 } # Metrics tracking read/write behaviour
|
|
28
29
|
@metrics_mutex = Mutex.new # Mutex for synchronizing access to metrics
|
|
30
|
+
@metrics_by_namespace = {} # Per-namespace metrics
|
|
31
|
+
@metrics_by_namespace_mutex = Mutex.new # Mutex for synchronizing namespace metrics
|
|
29
32
|
@max_value_bytes = nil # Optional size cap per value
|
|
30
33
|
@stop_expiry = false # Signal for stopping expiry thread
|
|
31
34
|
@max_ttl = nil # Optional maximum TTL for cache entries
|
|
@@ -34,13 +37,20 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
34
37
|
# --- Configuration Management ---
|
|
35
38
|
|
|
36
39
|
class << self
|
|
37
|
-
attr_accessor :serializer, :compress, :hard_memory_limit, :max_ttl, :default_ttl
|
|
40
|
+
attr_accessor :serializer, :compress, :hard_memory_limit, :max_ttl, :default_ttl, :eviction_threshold
|
|
38
41
|
attr_reader :max_bytes, :max_value_bytes
|
|
39
42
|
|
|
40
43
|
# Configures Mudis with a block, allowing customization of settings
|
|
41
44
|
def configure
|
|
42
|
-
|
|
45
|
+
new_config = config.dup
|
|
46
|
+
yield(new_config)
|
|
47
|
+
|
|
48
|
+
old_config = @config
|
|
49
|
+
@config = new_config
|
|
43
50
|
apply_config!
|
|
51
|
+
rescue StandardError
|
|
52
|
+
@config = old_config
|
|
53
|
+
raise
|
|
44
54
|
end
|
|
45
55
|
|
|
46
56
|
# Returns the current configuration object
|
|
@@ -48,6 +58,15 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
48
58
|
@config ||= MudisConfig.new
|
|
49
59
|
end
|
|
50
60
|
|
|
61
|
+
def bind(namespace:, default_ttl: nil, max_ttl: nil, max_value_bytes: nil)
|
|
62
|
+
Bound.new(
|
|
63
|
+
namespace: namespace,
|
|
64
|
+
default_ttl: default_ttl,
|
|
65
|
+
max_ttl: max_ttl,
|
|
66
|
+
max_value_bytes: max_value_bytes
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
51
70
|
# Applies the current configuration to Mudis
|
|
52
71
|
def apply_config! # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
53
72
|
validate_config!
|
|
@@ -57,6 +76,7 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
57
76
|
self.max_value_bytes = config.max_value_bytes
|
|
58
77
|
self.hard_memory_limit = config.hard_memory_limit
|
|
59
78
|
self.max_bytes = config.max_bytes
|
|
79
|
+
self.eviction_threshold = config.eviction_threshold
|
|
60
80
|
self.max_ttl = config.max_ttl
|
|
61
81
|
self.default_ttl = config.default_ttl
|
|
62
82
|
|
|
@@ -85,6 +105,9 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
85
105
|
raise ArgumentError, "max_value_bytes must be > 0" if config.max_value_bytes && config.max_value_bytes <= 0
|
|
86
106
|
|
|
87
107
|
raise ArgumentError, "buckets must be > 0" if config.buckets && config.buckets <= 0
|
|
108
|
+
if config.eviction_threshold && (config.eviction_threshold <= 0 || config.eviction_threshold > 1)
|
|
109
|
+
raise ArgumentError, "eviction_threshold must be > 0 and <= 1"
|
|
110
|
+
end
|
|
88
111
|
raise ArgumentError, "max_ttl must be > 0" if config.max_ttl && config.max_ttl <= 0
|
|
89
112
|
raise ArgumentError, "default_ttl must be > 0" if config.default_ttl && config.default_ttl <= 0
|
|
90
113
|
end
|
|
@@ -102,6 +125,7 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
102
125
|
@lru_tails = Array.new(b) { nil }
|
|
103
126
|
@lru_nodes = Array.new(b) { {} }
|
|
104
127
|
@current_bytes = Array.new(b, 0)
|
|
128
|
+
@inflight_mutexes = {}
|
|
105
129
|
|
|
106
130
|
reset_metrics!
|
|
107
131
|
end
|
|
@@ -111,7 +135,16 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
111
135
|
raise ArgumentError, "max_bytes must be > 0" if value.to_i <= 0
|
|
112
136
|
|
|
113
137
|
@max_bytes = value
|
|
114
|
-
|
|
138
|
+
threshold = @eviction_threshold || 0.9
|
|
139
|
+
@threshold_bytes = (@max_bytes * threshold).to_i
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def eviction_threshold=(value)
|
|
143
|
+
return @eviction_threshold = nil if value.nil?
|
|
144
|
+
raise ArgumentError, "eviction_threshold must be > 0 and <= 1" if value <= 0 || value > 1
|
|
145
|
+
|
|
146
|
+
@eviction_threshold = value
|
|
147
|
+
@threshold_bytes = (@max_bytes * @eviction_threshold).to_i
|
|
115
148
|
end
|
|
116
149
|
|
|
117
150
|
# Sets the maximum size for a single value in bytes, raising an error if invalid
|
|
@@ -141,9 +174,12 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
141
174
|
@lru_nodes = Array.new(buckets) { {} } # Map of key => LRU node
|
|
142
175
|
@current_bytes = Array.new(buckets, 0) # Memory usage per bucket
|
|
143
176
|
@max_bytes = 1_073_741_824 # 1 GB global max cache size
|
|
144
|
-
@
|
|
177
|
+
@eviction_threshold = 0.9
|
|
178
|
+
@threshold_bytes = (@max_bytes * @eviction_threshold).to_i # Eviction threshold at 90%
|
|
145
179
|
@expiry_thread = nil # Background thread for expiry cleanup
|
|
146
180
|
@hard_memory_limit = false # Whether to enforce hard memory cap
|
|
181
|
+
@inflight_mutexes_lock = Mutex.new
|
|
182
|
+
@inflight_mutexes = {}
|
|
147
183
|
|
|
148
184
|
# --- Core Cache Operations ---
|
|
149
185
|
|
|
@@ -161,6 +197,7 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
161
197
|
# Reads and returns the value for a key, updating LRU and metrics
|
|
162
198
|
def read(key, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
163
199
|
key = namespaced_key(key, namespace)
|
|
200
|
+
ns = namespace || Thread.current[:mudis_namespace]
|
|
164
201
|
raw_entry = nil
|
|
165
202
|
idx = bucket_index(key)
|
|
166
203
|
mutex = @mutexes[idx]
|
|
@@ -178,26 +215,26 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
178
215
|
promote_lru(idx, key)
|
|
179
216
|
end
|
|
180
217
|
|
|
181
|
-
metric(:hits) if raw_entry
|
|
182
|
-
metric(:misses) unless raw_entry
|
|
218
|
+
metric(:hits, namespace: ns) if raw_entry
|
|
219
|
+
metric(:misses, namespace: ns) unless raw_entry
|
|
183
220
|
end
|
|
184
221
|
|
|
185
222
|
return nil unless raw_entry
|
|
186
223
|
|
|
187
|
-
|
|
188
|
-
value
|
|
224
|
+
decompress_and_deserialize(raw_entry[:value])
|
|
189
225
|
end
|
|
190
226
|
|
|
191
227
|
# Writes a value to the cache with optional expiry and LRU tracking
|
|
192
228
|
def write(key, value, expires_in: nil, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
|
|
193
229
|
key = namespaced_key(key, namespace)
|
|
230
|
+
ns = namespace || Thread.current[:mudis_namespace]
|
|
194
231
|
raw = serializer.dump(value)
|
|
195
232
|
raw = Zlib::Deflate.deflate(raw) if compress
|
|
196
233
|
size = key.bytesize + raw.bytesize
|
|
197
234
|
return if max_value_bytes && raw.bytesize > max_value_bytes
|
|
198
235
|
|
|
199
236
|
if hard_memory_limit && current_memory_bytes + size > max_memory_bytes
|
|
200
|
-
metric(:rejected)
|
|
237
|
+
metric(:rejected, namespace: ns)
|
|
201
238
|
return
|
|
202
239
|
end
|
|
203
240
|
|
|
@@ -212,15 +249,18 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
212
249
|
evict_key(idx, key) if store[key]
|
|
213
250
|
|
|
214
251
|
while @current_bytes[idx] + size > (@threshold_bytes / buckets) && @lru_tails[idx]
|
|
215
|
-
|
|
216
|
-
|
|
252
|
+
evict_key_name = @lru_tails[idx].key
|
|
253
|
+
evict_ns = store[evict_key_name] && store[evict_key_name][:namespace]
|
|
254
|
+
evict_key(idx, evict_key_name)
|
|
255
|
+
metric(:evictions, namespace: evict_ns)
|
|
217
256
|
end
|
|
218
257
|
|
|
219
258
|
store[key] = {
|
|
220
259
|
value: raw,
|
|
221
260
|
expires_at: expires_in ? Time.now + expires_in : nil,
|
|
222
261
|
created_at: Time.now,
|
|
223
|
-
touches: 0
|
|
262
|
+
touches: 0,
|
|
263
|
+
namespace: ns
|
|
224
264
|
}
|
|
225
265
|
|
|
226
266
|
insert_lru(idx, key)
|
|
@@ -254,19 +294,29 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
254
294
|
old_size = key.bytesize + current_entry[:value].bytesize
|
|
255
295
|
new_size = key.bytesize + new_raw.bytesize
|
|
256
296
|
|
|
297
|
+
ns = current_entry[:namespace]
|
|
298
|
+
|
|
257
299
|
if hard_memory_limit && (current_memory_bytes - old_size + new_size) > max_memory_bytes
|
|
258
|
-
metric(:rejected)
|
|
300
|
+
metric(:rejected, namespace: ns)
|
|
259
301
|
return
|
|
260
302
|
end
|
|
261
303
|
|
|
262
304
|
while (@current_bytes[idx] - old_size + new_size) > (@threshold_bytes / buckets) && @lru_tails[idx]
|
|
263
305
|
break if @lru_tails[idx].key == key
|
|
264
306
|
|
|
265
|
-
|
|
266
|
-
|
|
307
|
+
evict_key_name = @lru_tails[idx].key
|
|
308
|
+
evict_ns = store[evict_key_name] && store[evict_key_name][:namespace]
|
|
309
|
+
evict_key(idx, evict_key_name)
|
|
310
|
+
metric(:evictions, namespace: evict_ns)
|
|
267
311
|
end
|
|
268
312
|
|
|
269
313
|
store[key][:value] = new_raw
|
|
314
|
+
|
|
315
|
+
# Refresh TTL on update. If no TTL applies, keep existing expiry (possibly nil).
|
|
316
|
+
now = Time.now
|
|
317
|
+
ttl = current_entry[:expires_at] ? (current_entry[:expires_at] - current_entry[:created_at]) : nil
|
|
318
|
+
store[key][:created_at] = now
|
|
319
|
+
store[key][:expires_at] = ttl ? now + ttl : nil
|
|
270
320
|
@current_bytes[idx] += (new_size - old_size)
|
|
271
321
|
promote_lru(idx, key)
|
|
272
322
|
end
|
|
@@ -287,15 +337,13 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
287
337
|
# The block is executed to generate the value if it doesn't exist
|
|
288
338
|
# Optionally accepts an expiration time
|
|
289
339
|
# If force is true, it always fetches and writes the value
|
|
290
|
-
def fetch(key, expires_in: nil, force: false, namespace: nil)
|
|
291
|
-
unless
|
|
292
|
-
cached = read(key, namespace: namespace)
|
|
293
|
-
return cached if cached
|
|
294
|
-
end
|
|
340
|
+
def fetch(key, expires_in: nil, force: false, namespace: nil, singleflight: false) # rubocop:disable Metrics/MethodLength
|
|
341
|
+
return fetch_without_lock(key, expires_in:, force:, namespace:) { yield } unless singleflight
|
|
295
342
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
343
|
+
lock_key = namespaced_key(key, namespace)
|
|
344
|
+
with_inflight_lock(lock_key) do
|
|
345
|
+
fetch_without_lock(key, expires_in:, force:, namespace:) { yield }
|
|
346
|
+
end
|
|
299
347
|
end
|
|
300
348
|
|
|
301
349
|
# Clears a specific key from the cache, a semantic synonym for delete
|
|
@@ -383,5 +431,33 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
383
431
|
val = compress ? Zlib::Inflate.inflate(raw) : raw
|
|
384
432
|
serializer.load(val)
|
|
385
433
|
end
|
|
434
|
+
|
|
435
|
+
def fetch_without_lock(key, expires_in:, force:, namespace:)
|
|
436
|
+
unless force
|
|
437
|
+
cached = read(key, namespace: namespace)
|
|
438
|
+
return cached if cached
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
value = yield
|
|
442
|
+
write(key, value, expires_in: expires_in, namespace: namespace)
|
|
443
|
+
value
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def with_inflight_lock(lock_key)
|
|
447
|
+
entry = nil
|
|
448
|
+
@inflight_mutexes_lock.synchronize do
|
|
449
|
+
entry = (@inflight_mutexes[lock_key] ||= { mutex: Mutex.new, count: 0 })
|
|
450
|
+
entry[:count] += 1
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
entry[:mutex].synchronize { yield }
|
|
454
|
+
ensure
|
|
455
|
+
@inflight_mutexes_lock.synchronize do
|
|
456
|
+
next unless entry
|
|
457
|
+
|
|
458
|
+
entry[:count] -= 1
|
|
459
|
+
@inflight_mutexes.delete(lock_key) if entry[:count] <= 0
|
|
460
|
+
end
|
|
461
|
+
end
|
|
386
462
|
end
|
|
387
463
|
end
|