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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ed8c4f85642ad10c8fc1d4213838004b647cd5a8f9da17dc4f9e8d99f45cb8b
4
- data.tar.gz: 4eed96ebdb4e701b9ba3b3d9b3e79d08b928215e2bd848f618775a48304160f2
3
+ metadata.gz: 987b789b060c3e66ed1ce29fc8fc868f9955e986349a48da34f2d06f29bf21f4
4
+ data.tar.gz: cb846d56202f3934cb1f4e47ede9d12ef3cd8af050cedd6e5f901945b95c41ef
5
5
  SHA512:
6
- metadata.gz: 98fe4110f82ba005e3f5b9d93e751341b6568d15e6052ee14544508ac3258912542d5e9a25a0c2b15c69326ee23e47885c6739bb93d946ae1443df6e740cc8e6
7
- data.tar.gz: e67673464ae604cd40e3c92d032757dd383a5f28b661fa5f630f9329d55c6f2b06096440495b91e9fcf91c55cc3b5943f7956a59b47104c9c8f4ce77fbb33bff
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
- | **Full Feature Support** | All Mudis features (TTL, compression, metrics, etc.) work transparently. |
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.
@@ -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
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- MUDIS_VERSION = "0.9.1"
3
+ MUDIS_VERSION = "0.9.4"
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
- yield(config)
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
- @threshold_bytes = (@max_bytes * 0.9).to_i
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
- @threshold_bytes = (@max_bytes * 0.9).to_i # Eviction threshold at 90%
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
- value = decompress_and_deserialize(raw_entry[:value])
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
- evict_key(idx, @lru_tails[idx].key)
216
- metric(:evictions)
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
- evict_key(idx, @lru_tails[idx].key)
266
- metric(:evictions)
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 force
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
- value = yield
297
- write(key, value, expires_in: expires_in, namespace: namespace)
298
- value
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